A real-time Planning Poker app for agile teams, built with Elixir + Phoenix LiveView. Create a room, share the link, vote on stories together β no accounts, no setup, no tracking cookies.
Live demo: https://lynxplanningpoker.com
- Real-time voting over Phoenix LiveView + PubSub β no polling, no page reloads
- No accounts β rooms are ephemeral, identified by a UUID link
- Fibonacci deck (0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ?)
- Host controls β reveal votes, reset round, end planning
- Average calculation when votes are revealed
- Internationalization β English (default), Portuguese (pt-BR), French, Spanish
- Light / dark / system theme
- Privacy-first analytics β first-party visit counting, no cookies, no third-party trackers
- Anti-abuse β Cloudflare Turnstile on room creation and per-IP rate limiting
- Elixir ~> 1.15 + Phoenix 1.8
- Phoenix LiveView 1.1 for the reactive UI
- PostgreSQL via Ecto (UUID primary keys)
- Tailwind CSS v4 + esbuild
- Bandit as the HTTP server
- Playwright for end-to-end tests
- Elixir >= 1.15
- PostgreSQL running on
localhost:5432with userpostgres/ passwordpostgres(or adjustapp/config/dev.exs)
A quick way to get Postgres up:
docker run --name postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16cd app
mix setupThis runs deps.get β ecto.create β ecto.migrate β run seeds.exs β asset build.
mix phx.serverOpen http://localhost:4000.
mix ecto.reset # drop + recreate + migrate + seeds
mix ecto.migrate # run pending migrations onlyUnit + LiveView (ExUnit), from app/:
mix test # unit + LiveView tests
mix test --failed # rerun only failed tests
mix precommit # compile + format + deps.audit + sobelow + credo + dialyzer + testEnd-to-end (Playwright), from e2e/:
Run against a Phoenix server already up at localhost:4000 (start it with mix phx.server in another terminal). They drive a real browser through the full UI β they do not boot the app themselves.
cd e2e
pnpm install
pnpm exec playwright install # first time only β downloads browser binaries
pnpm test # all specs, headless
pnpm run test:headed # visible browser
pnpm run test:ui # interactive Playwright UI
pnpm run report # open the last HTML reportSpecs cover the critical user flows: home/navigation, room creation, invite flow, voting, reveal/reset, multi-user real-time sync, host vs. guest actions, language switcher, and theme toggle. Full list and conventions in e2e/README.md.
Point at another environment with BASE_URL:
BASE_URL=https://staging.example.com pnpm testapp/
lib/
lynxplanningpoker/ # Contexts: Rooms, Users, Analytics
lynxplanningpoker_web/ # Controllers, LiveViews, components, plugs
config/ # config.exs, dev.exs, test.exs, prod.exs, runtime.exs
priv/
gettext/ # en, pt_BR, fr, es translations
repo/migrations/ # Ecto migrations
static/ # favicons, OG image, robots.txt
test/ # ExUnit + LiveView tests
e2e/ # Playwright end-to-end tests
LiveView entry point for the game room: app/lib/lynxplanningpoker_web/live/room_live/show.ex.
- The host creates a room at
/rooms/newβ one click, no name needed. We generate a temporary display name that can be changed inside the room at any time; once someone has named themselves, the browser remembers it and every later room (created or joined) reuses it without asking again. ARoomand a hostUserare created. - The host shares the
/rooms/invite/:idlink. - Each guest opens the link and joins with one click β they also get a temporary name to change inside the room β and is created as a
Userin the room. - Everyone lands on
/rooms/:id(a LiveView). Votes, joins, and reveals broadcast over PubSub in real time. - Only End planning closes the room for everyone. A dropped connection never does: someone whose browser froze or discarded their tab just loses their seat (presence-aware, after a grace period), and a host who comes back reclaims the role while nobody else holds it. A room left empty is reaped by a periodic sweeper.
The reference deployment runs on Fly.io. The app/fly.toml in this repo is the one used for the live demo β change app = and PHX_HOST before deploying your own copy.
Issues and pull requests are welcome. Before submitting:
- Run
mix precommitinsideapp/and make sure it passes. - If you change any user-visible string, run
mix gettext.extract --mergeand fill in the four.pofiles (en,pt_BR,fr,es). Keep themsgidin English (gettext's canonical source) and translate in each locale'smsgstrβ don't leave anymsgstr ""empty. - If your change affects UI, routes, or user flows, update or add the corresponding spec in
e2e/tests/.
MIT Β© Fernando Martens