A small Go CLI for picking a person at random from a team, like spinning a wheel of names. Designed for recurring team scheduling questions (who runs today's standup, who reviews the next PR, etc.) with built-in escalating "punishments" for repeat absentees.
- Go 1.26+
# build
go build
# run the binary
./WheelSpinner
# or skip the build step and run directly
go run .The app keeps its state in a SQLite file called wheel.db in the working
directory. The file is created and the schema migrated on first start, so
there is no setup step.
Everything is scoped to a team. A team has:
- a unique name,
- a roster of people,
- a cadence that controls how often the wheel may be spun (
daily,workday(Mon–Fri only),weekly,monthly), - optional punishment ratios — a list of decimal multipliers used to escalate the weight of repeat absentees.
When you start the app it lists existing teams and lets you pick one or create a new one. Once a team is selected, the main menu is:
- Spin the wheel — runs a single spin for the team's current cadence period, with the cascading punish flow described below.
- View spin history — the team's finalized spins, most recent first. Drilling into a spin shows its winner, the recorded criminals, and the section weights at the time of the spin.
- View future spins — a table of every team member's projected weight for the next N spins, where N is the longest active punishment schedule. Useful for eyeballing who is currently being escalated.
- Wipe all data — destructively deletes every team, person, spin,
section and criminal record. Requires typing
yesto confirm. Table schemas and migration history are left intact. - Quit.
A team can only record one finalized spin per cadence period:
daily— one spin per calendar day.workday— same as daily, but spins on Saturday/Sunday are refused.weekly— one spin per ISO week (Monday–Sunday). A Friday spin and the following Monday spin are different periods.monthly— one spin per calendar month.
If you try to spin again within the same period, the app prints when the last spin happened and refuses.
Every member of the team is on the wheel for every spin. Each section's
weight is the head of that person's punishment schedule — a per-person
list of decimal multipliers that burns down by one position after each
finalized spin. When the list is empty, the weight is simply 1.
The wheel animation draws each section sized proportionally to its weight, so a person currently under heavy punishment occupies a visibly larger slice and is more likely to be picked.
Once the pointer stops, you choose:
- Accept winner — finalize the spin with this winner. Every team member's schedule advances by one position.
- Respin — re-run the animation. Nothing is written to the database.
- Punish criminal — see below. Only offered when the team has at least one punishment ratio configured.
A person is only punished when the wheel lands on them while they are not present. Punishment is never retroactive: if they were in the room when the wheel picked someone else, they aren't a criminal for that spin, even if their weight was high.
When the wheel lands on an absent person, choose Punish criminal. The flow is:
- Their name is removed from the wheel for this spin (only in memory; the spin's stored sections still reflect the original lineup).
- The wheel is re-spun on the smaller wheel to pick a new winner.
- You can keep punishing — the cascade continues until a present person wins (or the wheel would be empty, in which case Punish is refused).
- When you finally Accept the surviving winner:
- The spin row is finalized with that winner.
- Each name you marked as a criminal is recorded in the
criminalstable for this spin. - Each criminal's punishment schedule is updated by stacking the team's ratios onto it (formula below).
- Every non-criminal team member's schedule advances by one position.
Until you Accept, nothing is written to the database, so respinning and punishing are free of side effects.
Given the team's ratios r = [r₀, r₁, … rₙ₋₁] and a person's existing
schedule s = [s₀, s₁, … sₘ₋₁], applying a punishment produces a new
schedule:
new[i] = sᵢ · r_(i mod n) for 0 ≤ i < m
new = new ++ r (the ratios are appended)
So every existing entry is amplified by the corresponding ratio (cycling
through the ratios if the schedule is longer than n), and a fresh copy
of the ratios is appended to the tail.
Ratios 3, 2, 1.5. A person is punished in consecutive spins, accepting
no other spin in between (so nothing burns the schedule down):
| punishments so far | schedule (index 0 = next spin's weight) |
|---|---|
| 0 | [] (weight = 1) |
| 1 | [3, 2, 1.5] |
| 2 | [9, 4, 2.25, 3, 2, 1.5] |
| 3 | [27, 8, 3.375, 9, 4, 2.25, 3, 2, 1.5] |
So after two punishments their next spin has weight 9, the one after 4, then 2.25, 3, 2, 1.5, and finally back to the default 1.
A single SQLite file (wheel.db) with these tables:
teams(id, name UNIQUE, cadence, punishment_ratios)people(id, team_id → teams, name, punishment_schedule)withUNIQUE (team_id, name)spins(id, team_id → teams, status pending|done, spun_at, winner_id → people)sections(id, spin_id → spins, person_id → people, weight)withUNIQUE (spin_id, person_id)criminals(id, spin_id → spins, person_id → people)withUNIQUE (spin_id, person_id)
Schema migrations live in migrations/, are bundled into the binary via
go:embed, and are applied automatically on startup using
pressly/goose.
To run migrations manually:
go run github.com/pressly/goose/v3/cmd/goose@v3.27.1 -dir migrations sqlite3 ./wheel.db status
go run github.com/pressly/goose/v3/cmd/goose@v3.27.1 -dir migrations sqlite3 ./wheel.db up
go run github.com/pressly/goose/v3/cmd/goose@v3.27.1 -dir migrations sqlite3 ./wheel.db down