The hero button said 'Join us this fall' while the nav button two
inches above it said 'Join us'. That is the whole problem in one
screenshot: strings that go stale on a schedule, scattered.
Seven values at the top of app.py now hold the program year, the
meeting day, and the two meeting times. Twelve inline meeting-time
strings and four program-year labels read from them, so a time change
is one edit rather than a grep. The block carries a note that this is
not a CMS: anything with a date in it belongs in the calendar feed,
and evergreen copy stays inline where git log can answer what the
site said during recruitment.
Two strings were not stale, they were false out of season:
'There is nothing to do between now and Tuesday' on the thanks page
and 'See you Tuesday' on the join CTA both confidently invited
families to a meeting that did not exist through July. Now 'we will
be in touch with the next meeting date' and 'See you on a Tuesday'.
The properly correct fix reads the next meeting from the calendar
feed and this is not that, but it stops the site making a promise
it cannot keep in August.
The chocolate booth does not run all winter. Three places now say
in season.
'fall grade' becomes 'current grade' on the join label and in
SHEET_HEADERS. The header is only written to an empty sheet, so the
live sheet would have kept saying 'fall grade' forever; sheet_append
now repairs cell E1 in place on the next lead, the same way it
already backfills the Comments header.
thanks_body became an f-string. It had no bare braces, so the
conversion is safe.
Two faults on a phone, both found on the live site.
The single-line clamp truncated a venue change at 'the Deep Creek
pa...', cutting the only part of the notice that mattered. Collapsed
now clamps to two lines, three under 560px. A SINGLE notice does not
clamp at all - it has no expand affordance, so clamping it loses text
with no way to get it back.
The banner scrolled away while the nav stayed pinned. That was a
deliberate call and it was wrong: a cancellation is worth more
viewport than the nav is. Banner and nav now share one sticky wrapper
so they pin together.
Expanded is exempt via :has() - an open three-notice banner pinned to
a phone viewport is the thing the original call was trying to avoid,
and it is still right about that. Open scrolls, collapsed sticks. The
40vh cap stays as the floor where :has() is unsupported.
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.
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.
The five troop entries were placeholders written to get the site up. Troop
73's real calendar lives in BAND, so those are replaced by what BAND
actually holds:
- First Court of Honor, Klondike Winter Derby, Troop Spring Campout,
Founder's Day, River Canoe Trip
+ Canoe Day (9/1), Gettysburg Trip (11/21), Lock In (12/12-13),
Cabin Camping at French Creek (1/16-17)
Excluded from BAND: six events already past (the /calendar page has no
date filter), the weekly Tuesday meeting series (runs to May 2027, and
the schema has no recurrence field - it is covered in page copy), and
the 10/17 Cub Scout Campout, which is the same event as the existing
Fall Campout entry and that one carries a start time.
Pack and both entries are untouched; BAND has no pack events.
Pack runs 6:00-7:15, so the troop follows at 7:30 with a buffer. Updates
all eight troop time references across the footer, /troop and /calendar.
The two 7:00 AM references (Green Lane farmers market chocolate booth)
are deliberately unchanged.
Planned timing, not yet established practice.