Fast and easy planning poker sessions for your whole team: free, secure, and open-source!
Always Free ♣ Unlimited Sessions ♠ Unlimited Participants
This is Free Planning Poker, a free tool for software teams to do "planning poker" exercises to estimate the difficulty and length of development tasks. You can probably use it for other purposes if you need, too. It's always going to be free, without limits.
The ideal scenario is that you can "clone and go" without much (if any) work, but there's a couple steps you need right now:
- Fork and clone this repo
- Download and install the .NET 10 SDK
- I recommend using VSCode with the C# Dev Kit
You should be good to go now - hit F5 and watch it run! By default it will use an in-memory store to keep state. This store is not thread safe; in order to get thread safety (and to allow SignalR to use a backplane) you'll need to provide a connection string for Redis. However, the in-memory store is fast and ideal for local debugging scenarios.
- You will need access to some deployment of Redis. Redis' Quick Start docs can help you here.
- Add your Redis connection string in appsettings:
"ConnectionStrings": {
"Redis": "<your-connection-string>"
}The application will see your connection string and use the Redis store instead of the in-memory store, and it will use Redis as a backplane for SignalR.
In future I want to look into Docker environments to be able to remove standing up your own Redis as being a burden.
FreePlanningPoker comes with a standalone Dockerfile that you can run in Docker.
FreePlanningPoker is published as a Docker image on Docker Hub. The container serves both the web client and the server on port 8080.
Note
End-to-end encryption relies on the browser's Web Crypto API, which is only available over HTTPS (or on localhost). Further, client<->server communication uses WebSockets. Ensure your network configurations support these.
docker run -d -p 8080:8080 --name free-planning-poker ianwold/free-planning-poker:latestThen open http://localhost:8080.
Without any further configuration the app uses its in-memory store. That's fine for trying it out or for a small team on a single instance, but the in-memory store is not thread safe and it can't be shared between instances. All sessions are also lost whenever the container restarts. For anything more serious, run it with Redis.
If you give the app a Redis connection string through the ConnectionStrings__Redis environment variable, it will use Redis to store sessions and as a SignalR backplane. This makes the app thread safe and lets you run several instances behind a load balancer.
If you already have a Redis instance:
docker run -d -p 8080:8080 \
-e ConnectionStrings__Redis="<your-connection-string>" \
--name free-planning-poker \
ianwold/free-planning-poker:latestOtherwise, you can run both together with Docker Compose. As an example, this is a minimal compose.yaml:
services:
app:
image: ianwold/free-planning-poker:latest
ports:
- "8080:8080"
environment:
ConnectionStrings__Redis: redis:6379
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stoppedThen start it with:
docker compose up -dThe connection string follows the StackExchange.Redis configuration format, so you can add options like a password or TLS as needed (e.g. my-redis:6380,password=secret,ssl=true). Session data doesn't need to survive a Redis restart: every key has a 24-hour TTL, so you don't need to set up Redis persistence.
The server exposes two endpoints for your load balancer, orchestrator, or uptime monitor:
/health/livereturns200whenever the server process is up and serving requests. It doesn't check Redis, so it's the one to use for restarting a stuck container./health/readyreturns200when the server can reach Redis (it sends aPINGwith a 3-second timeout), and503otherwise. With the in-memory store it always returns200. Use this one for routing traffic and for alerting.
If you've made changes of your own, you can build the image from the Dockerfile at the root of the repo:
docker build -t free-planning-poker .
docker run -d -p 8080:8080 free-planning-pokerThe web client is a Blazor WASM SPA, the server is ASP and they communicate exclusively over SignalR (websockets). The server uses Redis as a backplane for SignalR and to store active sessions - this allows the server to scale horizontally.
The SignalR communication is defined by two interfaces in the PlanningPoker project: IServer defines client-to-server communication (some of which does require a round trip) and IClient defines server-to-client communication (none of which requires a round trip; this must be asynchronous communication).
The logic for the server methods is in SessionHub in the Server project. This class contains the very minimal business rules and the scheme of notifying clients of changes through IClient. Clients are grouped by session id, and only clients in a session will receive notifications for it.
State is kept by one of the two classes implementing IStore: either InMemoryStore or RedisStore. The former is used for local debugging scenarios where Redis isn't strictly needed, while the latter is used for production deployments and any networking-related debugging and testing.
If you are adding a method on the server for the client to call, you'll update IServer, implement the server logic in SessionHub and the store classes, then you'll update the client's Client class to call it (see below). If you're adding a method on the client to call, you'll update IClient, implement the client logic in Client and SessionStore (see below), then you'll update the server's SessionHub to call down through that method. Everything is strongly-typed by these interfaces on both the client and server, keeping you from needing to using magic strings.
Configuration and dependency injection are all set up in Program; there's really not a lot there.
Session data is stored in Redis across several keys to eliminate or minimize race conditions. The keys and their values are:
{sessionId}(guid): Hash with values "Title" and "State".{sessionId}:points: List with values being the point options available in the session.{sessionId}:participants: List with values being the IDs of the participants in the session.{sessionId}:participants:{participantId}: Hash with values "Name", "Points", and "Stars".
All entries associated with a session are removed from Redis when the last participant leaves the session. For extra safety, the keys all have a 24-hour TTL.
The Client is separated into two pieces - a logical client in PlanningPoker.Client and the web (Blazor) view in PlanningPoker.Client.Web. The logical client is split by responsibility rather than one big class:
- SessionStore holds the session data a UI needs to render - the current
Session,Self/Others,ShowShareNotification, etc. - and raisesChangedwhenever any of it updates. Nothing outsidePlanningPoker.Clientcan write to it directly; its state only changes through its own methods. - ToastStore holds the transient notification toasts shown in the UI, independently of session data, with its own
Changedevent. - Client is the piece that actually knows about SignalR - it implements
IClient, keeps an instance ofIServer, and exposes the methods a UI calls to act (CreateAsync,UpdatePoints, etc). It writes intoSessionStore/ToastStoreas a side effect of handling server callbacks or fulfilling those calls, but exposes nothing to read itself - components should consult the stores for state and callClientonly to perform actions.
Platform-specific behavior (building the hub URL, what to do when a session is created or closed, any JS interop needed at startup) is captured by the ISessionTransport interface, which Client takes as a constructor dependency rather than through subclassing. This keeps Client itself free of any browser-specific dependency and lets a different UI (or the integration tests) supply its own transport.
Client's connection is set up in EnsureInitialized, and torn down in LeaveAsync. When adding server functionality, you'll typically only need to change Client (for the SignalR/actions side) and SessionStore (if the change adds or updates data a UI needs to read) unless it also requires new UI components.
On the Blazor client, there's two main files to care about: WebSessionTransport (the browser's ISessionTransport implementation) and SessionPage.
SessionPage is the user interface for almost the entire application. The user will first create a session on the homepage (Index.razor) but then all the work in the session is done on this page. Components inject SessionStore (and ToastStore, where relevant) to read state and subscribe to Changed, and inject Client separately to perform actions. Several components for the UI are broken out into separate Razor components in the Components directory.
Dark/light color mode is handled directly through JS in index.html. E2E encryption is implemented there too, but Client never calls into JS for it directly - it depends on the IEncryptionService interface, which EncryptionService implements via JS interop. This keeps the logical client itself free of any browser-specific dependency, so a non-Blazor client could supply its own IEncryptionService instead. I wrote a blog post on E2E encryption which covers the implementation used here.
Integration tests live in PlanningPoker.IntegrationTests. They deliberately drive the app only through Client, SessionStore, and ToastStore (via a TestSessionTransport implementation of ISessionTransport, the same pattern WebSessionTransport follows) rather than reaching into SessionHub or the store classes directly - the goal is to test the behavior a participant actually sees, so the suite doesn't need to change every time something internal gets refactored.
Each behavior is written once and run in up to three modes: against InMemoryStore, against RedisStore with a single server instance, and (for tests with more than one participant) against RedisStore with two server instances sharing one Redis, to prove the SignalR backplane actually relays messages between them. The Redis-backed runs spin up a real Redis container via Testcontainers, so you'll need Docker running locally to execute the full suite - dotnet test will otherwise fail on those specific tests while the InMemoryStore ones still pass fine.
Please do! I think the above gives a fair quick overview of the project structure and how to add some features. I've got several good first issues and I'm always happy to discuss suggestions for what to include, modify, etc.
If you would like to champion an issue, please leave a comment saying you'd like to - I'll assign the issue to you and I'll be happy to clarify any questions.
I don't have formal code standards on this proejct yet; it's quite small and young. I ask that your code be kept minimal, tidy, and in-keeping with the code that's already here. In future as the application solidifies then a more defined coding and architectural standard will probably emerge - I find that a codebase will generally reveal its own standards over time and I prefer allowing that process rather than imposing a (probably wrong) idea on the codebase from the start.