6.3 KiB · text History 25c4237

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/nearby first, 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