fieri-eternal
Where are we going for lunch? Not there again.
People pin restaurants on an OpenStreetMap map and rank each one Obsessed (since a date), Always, Positive, Neutral or Never. When a group goes out, the app suggests somewhere from what everyone going likes and where the team hasn’t been lately, and the group logs where it actually went so the next pick knows.
Restaurants are grouped into areas: the office, or a city someone is travelling to. Suggestions, history and the map all work within one area, and the planner can lay out several meals in a row (a week of lunches, or a trip) without repeating itself.
A revival of a decade-old work slackbot, as a web app: axum + htmx +
minijinja, sea-orm on Postgres, and Argon2 session auth with invite-only
registration. Maps, search and place details come from the self-hosted
osm-tiles stack (deployment/argo-01-bos/osm-tiles).
Front-end is server-rendered HTML with Tailwind + DaisyUI; htmx handles boosted navigation and live partial updates — no SPA build step.
Generated from the neiam-htmx
cargo-generate template.
Stack
| Concern | Choice |
|---|---|
| HTTP | axum 0.8 |
| Hypermedia | htmx (hx-boost, polling), axum-htmx |
| Templating | minijinja (embedded at build time) |
| Database | Postgres via sea-orm (UUIDv7 keys) |
| Migrations | sea-orm-migration (auto-run on boot) |
| Auth | Argon2 password hashing, cookie sessions, invite tokens |
| Styling | Tailwind CSS + DaisyUI + FontAwesome |
| Metrics | Prometheus at /metrics |
| Maps | Leaflet on tiles.neiam.org raster tiles, themed to match the page |
| Place lookup | Photon (/geocode/) and the POI sidecar (/poi/), called server-side |
Binaries
webservice— the HTTP server.registration-token— CLI to mint/list invite tokens (bootstrap the first user).
Quick start (local dev)
Prereqs: Rust, just, Node.js (for the asset
build), and Docker/Podman (for Postgres).
# 1. Build front-end assets (CSS/JS/webfonts)
just deps
just assets
# 2. Start Postgres
just infra
# 3. Run the web server (auto-runs migrations on boot)
just serve # -> http://localhost:4011
# 4. Mint an invite so you can register, then open the printed URL
just invite
Registration is invite-only by default. Set REGISTRATION_ENABLED=true to let
anyone with a valid invite token self-serve, or keep it off and hand out links
from the in-app Invites page.
Areas, members and friends
Areas are members-only: to anyone else an area, its restaurants, lunches and votes don’t exist (404). Each member has a role:
| Role | Can |
|---|---|
| voter | see the area, rank, start/join/vote on lunches, log visits |
| editor | also add, edit, archive and refresh restaurants |
| admin | also rename the area and manage members and invitations |
Whoever creates an area is its admin, and an area always keeps at least one.
Admins invite people from the area’s Members page: an existing user gets a
pending invitation on their home page to accept or decline; someone without an
account gets a sign-up link from Invites, which can carry any areas the
inviter administers (with a role each), joined on registration. The checks
live in src/access.rs; every handler goes through them.
Friends are mutual (request, then accept). The “who’s going” picker lists you, then members who are your friends or have eaten with you in that area, most shared lunches first; everyone else in the area is folded away. Admins are offered their friends as quick invites. Restaurants show who contributed them.
The same OSM place can be in several areas (it’s unique per area), and area names only need to be unique within your own list, since other people’s areas are private.
Deciding together: outings
“Start a vote” on an area’s planner opens an outing: a ballot of a few options (3 by default) for today. Share the link; people join, and vote for every option they’d happily go to (approval voting). The most-voted option is marked as leading, and anyone can lock one in with “Go here”, which logs the lunch for everyone going.
Re-roll keeps every option that has a vote and swaps the rest for places this outing hasn’t shown yet, re-drawn for whoever is going now. Only when an area runs out of fresh places does it reach back for ones rolled away earlier. If someone who joined after a draw ranked one of the options “never”, it’s flagged rather than silently swapped out.
Outing pages update live over SSE. Each change runs pg_notify('outing_changed', <id>); every replica LISTENs and passes the id to its open streams, and the
page re-fetches its panel when it gets the event. Because it goes through
Postgres, this works across any number of pods. A 30 s poll covers a dropped
stream. If the app sits behind ingress-nginx, the X-Accel-Buffering: no
header the stream sends stops events being buffered; long-lived connections
may also need proxy-read-timeout raised past the 15 s keep-alive.
How suggestions work
All in src/suggest.rs, pure functions with unit tests. For the people going:
- Veto. If anyone going ranked a place Never, it’s out.
- Liked. The group’s average weight: Obsessed 6, Always 4, Positive 2, Neutral 1. Obsessed records when it started; picking it again keeps that date, and it can be backdated from the rank widget. Unranked counts as Neutral, so nobody has to rank everything.
- Open. If OSM says a place is closed at the meal time (noon by
default; the planner and votes take a time), it’s out. Unknown or
unreadable hours count as open. Parsed with the
opening-hourscrate at naive local time, so trip areas in other time zones need no tz handling. - Freshness.
1 / (1 + 3 × load), where load sums every visit in the last 70 days, each decayed by a 10-day half-life. Yesterday’s spot keeps about a quarter of its weight; one from a month ago is mostly forgiven.
Score is liked × freshness, and the pick is a weighted random draw on score, so a favourite wins most days without winning every day. “Re-roll” draws again. A multi-meal plan counts each pick as a visit before scoring the next.
History is per team, not per person: if anyone logged a lunch at a place, it counts as recent for everyone.
Maps and place lookup
The browser loads tiles straight from TILES_URL. That server only serves
referers on its allowlist (*.neiam.org, *.neiam.co, localhost), so
deploy the app under one of those domains or the map will be blank 403s.
The map’s tile theme follows the page’s daisyUI theme (assets/f/js/map.js).
Search and “what’s here?” are proxied through the app (/places/*), so they
need no CORS and send no referer:
- Search goes to Photon, restricted to eateries and ranked by distance from the area’s centre.
- Clicking the map asks
/poi/nearbyfirst, which carries cuisine and website. If that fails (it has a 4 s timeout upstream and 504s when it trips), the app falls back to Photon reverse geocoding and skips POI for the next 5 minutes, so every click doesn’t wait 4 s for the timeout.
Every candidate with an OSM ref then gets its cuisine, website and
address from its OSM tags, all in one batch: /poi/tags first (fast, even
when /poi/nearby is timing out), then the public OSM API (OSM_API_URL) for
anything the tile server’s import predates. Picking a candidate copies them
into the form. Places OSM has no cuisine tag for stay blank to type in.
Restaurants added before hours were tracked can pick them up with Refresh from OSM on the restaurant page, or for every OSM-linked place at once under Manage on the area page. Refresh replaces hours (they change) but only fills cuisine, website and address where blank.
The search box also takes an OSM id: 29632970 (tries node, way and
relation, keeping whichever is a named place), w29632970, way/29632970,
or a pasted openstreetmap.org link. It’s looked up on the OSM API, placed at
the centre of a way’s or relation’s outline, and shown however far away or
whatever kind of place it is, since you asked for exactly that one.
Search results from an area are sorted nearest its centre first, and anything over 50 km away is dropped (Photon will otherwise offer a namesake on another continent).
Similar nearby, on a restaurant’s page, lists places with the same
cuisine: those already in the area, nearest first, and OSM places within
1.5 or 5 km that aren’t yet, which editors can add in one click. It asks
/poi/nearby for the cuisine; when that’s down, it searches Photon for the
cuisine word and keeps only results whose tags really carry it, so places
without the word in their name are missed until POI is back.
Cuisines (/cuisines, optionally ?area=) lists every cuisine tag with
counts; each tag’s page shows every matching restaurant grouped by area, with
your rank and whether it’s open at noon today. Cuisine tags link there
everywhere they appear.
Anything OSM doesn’t know about can be added by dropping a pin and typing the name. Adding an OSM place that’s already here just records your rank on the existing one.
Configuration
All via environment variables (defaults suit local compose.yml):
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
postgres://app_user:app_password@localhost:5441/app |
Postgres DSN |
BIND_ADDRESS |
0.0.0.0:4011 |
Listen address |
AUTO_MIGRATE |
true |
Run migrations on webservice boot |
REGISTRATION_ENABLED |
false |
Allow public self-registration |
SESSION_DURATION_HOURS |
24 |
Session lifetime |
REGISTRATION_TOKEN_DURATION_DAYS |
7 |
Default invite lifetime |
TILES_URL |
https://tiles.neiam.org |
Tile server base; themes are /{theme}/{z}/{x}/{y}.png |
GEOCODE_URL |
https://tiles.neiam.org/geocode |
Photon; empty disables search |
POI_URL |
https://tiles.neiam.org/poi |
POI sidecar; empty uses Photon only |
OSM_API_URL |
https://api.openstreetmap.org |
Tags for places newer than the POI import; empty disables |
DEFAULT_LAT / DEFAULT_LON |
Boston | Where the “new area” map opens |
Containers
just up # build + run postgres, webservice
just logs # tail the webservice
just invite-in-pod
just down
Layout
src/
bin/ webservice, registration-token
config.rs env-driven configuration
lib.rs AppState + minijinja environment
entities/ sea-orm models (auth, areas, restaurants, rankings, lunches, outings)
auth/ password hashing, sessions, invite tokens, user ops
middleware/ cookie-session auth layers
handlers/ axum handlers (auth, areas, restaurants, places, lunch, outings)
osm.rs Photon + POI client
live.rs LISTEN/NOTIFY → SSE relay for outings
suggest.rs scoring and planning
migration/ sea-orm migrations
templates/ minijinja templates (excluded from generation templating)
assets/ Tailwind/esbuild sources (incl. the Leaflet glue in f/js/map.js) + vendored fonts
tests/ template render smoke tests
Regenerating entities after a schema change
sea-orm-cli generate entity -u "$DATABASE_URL" -o src/entities