fieri-eternal
Where are we going for lunch? Not there again.
People pin restaurants on an OpenStreetMap map and rank each one 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.
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: Always 4, Positive 2, Neutral 1. Unranked counts as Neutral, so nobody has to rank everything.
- 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.
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 |
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)
auth/ password hashing, sessions, invite tokens, user ops
middleware/ cookie-session auth layers
handlers/ axum handlers (auth, areas, restaurants, places, lunch)
osm.rs Photon + POI client
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