code for managing the yard sale for the boise neighborhood association
  • TypeScript 54.4%
  • HTML 45.6%
Find a file
2026-09-22 07:30:20 +00:00
.claude Design: host the map on celilo, refresh it on a timer, keep the review 2026-09-07 14:37:50 -07:00
.forgejo/workflows fix(ci): bump to 0.3.1, and stop pr-validate passing the publish gate blindly 2026-09-08 19:38:15 -07:00
celilo CI/CD: forgejo builds and publishes the .netapp to celilo-registry 2026-09-07 19:55:56 -07:00
docs Point the repo at the celilo org, so the runner can see its jobs 2026-09-07 20:24:15 -07:00
openspec docs(openspec): mark task 6.3 done 2026-09-07 21:58:55 -07:00
scripts deps(celilo): track public_web 4.0.0, and pin the 4.x CLI 2026-09-20 22:05:56 -07:00
site data: refresh from the published sheet (33 sales, 11 notes) 2026-09-22 00:24:31 -07:00
tests ci: pin the packaging CLI, and confirm the suite on released toolchain 2026-09-08 16:01:26 -07:00
.gitignore test(e2e): get the cele2e deploy suite running, and fix what it caught 2026-09-07 21:25:05 -07:00
.mise.toml chore(toolchain): move CI and dev to bun 1.4.2 2026-09-20 22:05:56 -07:00
bun.lock deps(celilo): track public_web 4.0.0, and pin the 4.x CLI 2026-09-20 22:05:56 -07:00
csv-to-json.test.ts feat(pipeline): carry the seller's comment, and stop blanking the data 2026-09-22 00:24:26 -07:00
csv-to-json.ts feat(pipeline): carry the seller's comment, and stop blanking the data 2026-09-22 00:24:26 -07:00
exclude.txt Rebuild the yard sale pipeline and document the whole process 2026-09-07 13:32:04 -07:00
freeya-logo.png 🚧 Work in progress 2025-09-21 00:25:28 -07:00
geocode-cache.json data: refresh from the published sheet (33 sales, 11 notes) 2026-09-22 00:24:31 -07:00
manifest.test.ts test(e2e): get the cele2e deploy suite running, and fix what it caught 2026-09-07 21:25:05 -07:00
manifest.yml chore(release): 0.4.0 2026-09-22 00:24:36 -07:00
package.json chore(release): 0.4.0 2026-09-22 00:24:36 -07:00
pipeline.integration.test.ts test(e2e): get the cele2e deploy suite running, and fix what it caught 2026-09-07 21:25:05 -07:00
README.md deps(celilo): track public_web 4.0.0, and pin the 4.x CLI 2026-09-20 22:05:56 -07:00
squarespace-snippet.html data: refresh from the published sheet (33 sales, 11 notes) 2026-09-22 00:24:31 -07:00
squarespace-snippet.template.html feat(pipeline): carry the seller's comment, and stop blanking the data 2026-09-22 00:24:26 -07:00
tsconfig.json e2e: a real cele2e deploy, and stand-in Google and Nominatim servers 2026-09-07 20:02:35 -07:00
yard-sale-info.json data: refresh from the published sheet (33 sales, 11 notes) 2026-09-22 00:24:31 -07:00

BNA Yard Sale Map

The Boise Neighborhood Association runs a neighborhood-wide yard sale on the last Saturday in September. Neighbors sign up through a Google Form. This repo turns those signups into a map that goes on the BNA's Squarespace site.

The map is a celilo module. It serves as a static site at bna-yard-sale.org, and the Squarespace page links to it.

The data is still hand-run: you export the form responses, run the pipeline, build the site, and deploy. Automating that is add-data-refresh, and it is not built yet.

The process, end to end

  1. Neighbors fill out the Google Form. One response per household.
  2. You export the responses as CSV and save it here as yard-sale-info.csv.
  3. You run bun run convert-csv. It geocodes each address against OpenStreetMap, drops the junk, and writes the data three places: yard-sale-info.json, site/data/sales.json (what the site serves), and squarespace-snippet.html (the contingency, below).
  4. You run bun run build:site. It renders site/dist, and refuses to build a page that would render seller text as HTML.
  5. You deploy the module. celilo module deploy bna-yard-sale.
  6. You repeat whenever enough new signups come in. The geocode cache means a re-run only hits the network for addresses it has not seen.

Verify by the artifact, not the exit code:

curl -sSI https://bna-yard-sale.org | head -3
curl -s  https://bna-yard-sale.org/sales.json | head -5

If the fleet is down on the morning of the sale

squarespace-snippet.html is still generated every run, and it is a self-contained map that needs nothing but Squarespace. Paste it into a Code block on the BNA page and the neighborhood has a map again. Frozen at whatever the last pipeline run produced, which on the day is fine.

Where things live

Thing Where
Google Form (public link) https://docs.google.com/forms/d/e/1FAIpQLSfJ-B7u6SO5qSzqdUaNpSJv4V5_TyDZUmZ_LOelnJoZR0QPCg/viewform
Google Form (edit) In Google Drive under peter.banka@gmail.com, search "Boise Neighborhood Yard Sale"
Response spreadsheet Linked from the form's Responses tab, "View in Sheets"
The web page The BNA Squarespace site, yard sale page

The form and the spreadsheet both live in the peter.banka@gmail.com Google account. That is a single point of failure. See "Open questions" below.

Running it

bun install
bun run convert-csv

yard-sale-info.csv is deliberately not in git. The form export pairs each neighbor's name and email with their home address, and only the address and the goods list were ever meant to be public. Those two fields are what yard-sale-info.json carries, so the generated data is safe to commit and the raw export is not.

To get the CSV: open the form, Responses tab, the three-dot menu, "Download responses (.csv)". Unzip it if Google hands you a zip. Save the CSV over yard-sale-info.csv.

The script prints every sale it kept and every row it dropped, with a reason for each drop. Read that output. It is the only review step in the process.

When it asks you for an address

If OpenStreetMap cannot place an address, or places it outside the neighborhood, the script stops and asks you to retype it. Look the address up, type a version that Nominatim will understand, and press Enter. Press Enter on an empty line to skip that seller entirely.

Most of the time this happens because someone typed an apartment number, a cross street, or "the alley behind X". Strip it down to a street number and a street name.

What gets dropped automatically

  • A row with no address.
  • A row where nobody listed anything for sale.
  • A second response from an address that already appeared. The later response wins, because a household submitting twice is correcting itself.
  • Anything you have listed in exclude.txt.
  • An address that could not be placed on the map after you were asked to fix it.

Every drop is printed with a reason. If something real got dropped, fix the address in the spreadsheet and re-export.

What gets flagged but kept

Sales that geocode to the same point are listed at the end of the run and never dropped. A shared point means one house typed two ways, but it just as often means a duplex, an apartment building, or a house and the ADU behind it, where the neighbors really are selling separately. Only you can tell those apart, so the script asks instead of deciding. Add one to exclude.txt if it is a repeat, or leave both if they are not.

The 2025 data has one: 3720 N Michigan Ave and 3720 N Michigan Ave / Alley Garage. Two listings, one address, and genuinely ambiguous.

Dropping a row by hand

Some responses are neither empty nor duplicated, but still are not yard sales. In 2025 one person wrote "Nothing I'm trying to find the address or a map" in the goods field. A computer cannot tell that apart from a real listing, so put the address in exclude.txt, one per line. Comments start with #.

The files

File What it is
csv-to-json.ts The whole pipeline. Read this if anything surprises you.
csv-to-json.test.ts Covers the address matching, sanitizing, and column lookup.
yard-sale-info.csv The Google Form export. Not in git, see above. You overwrite this each time.
yard-sale-info.json Generated. The clean, geocoded list.
squarespace-snippet.template.html The page source. Edit this one.
squarespace-snippet.html Generated. The thing you paste. Never edit it.
geocode-cache.json Address to coordinates, so re-runs stay fast and quiet.
exclude.txt Addresses you have decided are not real sales.
freeya-logo.png Sponsor logo, kept as a local backup of the hosted one.
manifest.yml The celilo module: what it needs, what it builds, what it runs.
scripts/on_install.ts Deploy hook. Publishes site/dist through public_web.
scripts/register-forge.ts Optional. Mints the repo's scoped CI publish token.
scripts/health_check.ts Checks the page answers and that sales.json holds drawable sales.
site/index.template.html The page. Edit this one.
site/build.ts Renders site/dist, and refuses to build an unsafe page.
site/data/sales.json Generated. What the page fetches at load.
site/dist/ Generated. What celilo publishes. Not in git.

Design notes worth knowing

Every geocode query carries the city. Nominatim guesses wildly when you hand it a bare street address, and for the first two years this pipeline did exactly that. That is why the 2025 run kept stopping to ask for corrections, and why one address landed a mile south of the neighborhood.

Results are checked against a bounding box. A geocode that lands outside roughly the Boise neighborhood is treated as a bad match, not a distant neighbor, and you get asked to fix it. The box is NEIGHBORHOOD_BOUNDS in csv-to-json.ts. If the sale ever grows past those streets, widen it there.

The map builds popups out of text nodes, never HTML strings. The goods description is whatever a stranger on the internet typed into a public form, and it ends up on the neighborhood association's website. Building the popup with textContent means a <script> tag in that field renders as the literal characters instead of running. Do not "simplify" this back to string concatenation with innerHTML.

The snippet is generated, so it cannot drift. In 2025 the repo had two copies of the page with two different sets of sales, six days apart, and no way to tell which one was live. Now there is one template and one generated file.

The column lookup matches question text, not column position. The old code read the address out of column 4 and required exactly six columns, so adding a question to the form would have silently skipped every row. Now it finds the address column by matching ^address against the headers, and fails loudly with the actual header list if it cannot.

Geocoding is rate-limited to one request every 1.1 seconds. Nominatim is a free service run on donated hardware, and their usage policy caps anonymous callers at one request per second. Going faster gets your IP blocked, which on an unattended run would show up as an empty map with no obvious cause. A full cold-cache run of forty addresses therefore takes about a minute. That is fine.

The page fetches its data instead of embedding it. sales.json sits beside index.html and is fetched from the same origin at load. That keeps a data change to one file, which is what makes the phase-two refresh reviewable. The page renders a usable header, date and message when the fetch fails, and says plainly that the list could not be loaded rather than claiming nobody signed up.

The date is computed, never typed. The sale is the last Saturday in September, so the page derives it, and rolls to next year the day after this year's. Nothing to update in September 2027.

The build refuses to ship an unsafe page. site/build.ts fails if the page uses innerHTML, if it reaches a geocoder at runtime, or if the seller description stops going through the textContent helper. Those are the ways this page could hurt somebody, so they stop the build rather than get noticed later.

The geocode cache stores coordinates only, never descriptions. Descriptions change every year. Coordinates do not. The cache carries over between years, so a repeat seller costs no network call.

Checks

bun run check      # everything below, in order, stopping at the first failure

That is five gates. build:site is one of them, and it is a real gate rather than a build step: it refuses to write a page that reaches for innerHTML, geocodes in the browser, stops routing seller text through the textContent helper, or has no title.

The hooks are a separate package with their own package.json, exactly as celilo modules are on the fleet, so run bun install inside scripts/ once.

All of it must be clean before you deploy, or before you paste the contingency snippet into the live site.

The deploy suite

bun run test:e2e   # cele2e run .

A real deploy of this module inside cele2e's simulated internet: real Caddy, real ACME, real DNS through a fake registrar, on a host celilo provisions. It asserts the served page renders seller text as text and that sales.json holds sales inside the neighborhood, because a page that answers 200 with nothing behind it is the failure worth catching.

Three things to know before the first run.

Run cele2e build-infra once on the machine. It downloads the standard modules the suite deploys into the harness's cache. Skip it and the run fails four stages in with Module 'iptables' not found in registry, which reads as a broken registry rather than an empty cache.

The rig is one machine-global resource behind a run lock. cele2e status says whether it is free. This is why the suite does not run in CI.

Bumping @celilo/e2e un-stages the standard modules. build-infra writes the fetched .netapps inside node_modules/@celilo/e2e, so reinstalling that package throws them away. The next run then fails at stage one with Standard modules are not staged, which names the symptom rather than the cause. Re-run cele2e build-infra after any @celilo/e2e version change.

First-time setup

If you are setting this up rather than running it, see docs/one-time-setup.md: the Google Form, publishing the responses as CSV without leaking names and emails, the domain, and the forge.

Open questions

These are decisions nobody has made yet, written down so they do not get lost.

  • The form and spreadsheet live in one personal Google account. When somebody else runs the sale, they cannot get at them. Moving the form to a BNA-owned account, or adding a co-owner, would fix that.