Automatic fixture scheduling for badminton leagues.
Smash Dates is a web application for running multi-club badminton leagues end to end: set up clubs, teams, venues and divisions; configure seasons and playing weeks; then let the built-in scheduler generate a complete home-and-away fixture list that respects every real-world constraint — venue availability, blocked dates, derby-first rules and gender/week-type matching. Clubs confirm or reject their fixtures, results roll up into live standings, and a calendar-driven background service moves seasons through their lifecycle automatically.
The whole thing ships as a single container: a .NET 10 API that also serves the Angular client, backed by PostgreSQL.
Contents: Features · Screenshots · Quick start · Email configuration · Tech stack · License
Access & roles
- Email/password accounts with cookie authentication; the first registered user becomes the SystemAdmin.
- Email verification gates login — new sign-ups confirm their address via a one-time link before they can sign in (the bootstrap SystemAdmin is auto-verified). Self-service password reset and resend verification round out the flow; tokens are single-use, hashed at rest and expire — re-issuing a link invalidates the previous one, and spent tokens are pruned by a background job. Links ride the notification outbox (logging sender by default).
- Profile page (
/profile) — a signed-in user can edit their display name (the name shown around the app; blank falls back to their email), change their own password (verifies the current password, BCrypt-hashes the new one) and review a read-only list of the role grants they hold (SystemAdmin, LeagueAdmin, ClubAdmin, SessionHost, each naming its league/club). The password change is the authenticated self-service path; the logged-out email-link reset is separate and untouched. - Three role scopes: SystemAdmin (bootstrap), LeagueAdmin@League and ClubAdmin@Club — granted per league/club, with last-admin protection.
- Public view — a logged-out read-only view of a league's standings and fixtures (
/public), PII-free (team/division/venue names, dates and scores only).
League setup
- Leagues and Divisions (gender, rank, rubbers-per-match, configurable points scheme).
- Clubs (open registry with short codes), Teams (fixed gender) and Venues (a court count plus a max-concurrent-matches ceiling; a match occupies several courts, set per league) — each with an optional address that links out to Google Maps from the venues tab and upcoming club nights.
- Club ↔ League memberships with a full lifecycle: invite → accept / decline → withdraw / expel, with mid-season locks.
- Bulk CSV import for clubs, teams, venues, season entries and club players — partial import with a per-row report, upsert on match, and a downloadable template per importer. The players importer always creates a fresh player per row; duplicate identities across clubs are reconciled later by a separate merge, not matched on import.
Seasons
- Seasons with an explicit ordered list of Weeks (Level vs Mixed), validated for non-overlap and in-range.
- Season entries assign teams to divisions per season (promotion/relegation without losing identity), gated by gender match and accepted membership.
- Lifecycle
Draft → Scheduling → Proposed → Active → Closed. Generation runs as a background job (the season sits inSchedulingwhile a worker builds the fixtures, then moves toProposed, or back toDraftwith a reason if it can't); season transitions are otherwise automatic and date-driven (with manual admin overrides). - Blocked dates at Venue, Club or Team scope, locked once a season is Active.
The scheduler
- Custom heuristic engine (no external solver): Berger double round-robin → derby-first ordering → greedy placement honouring all hard constraints (one match per team per date, venue capacity, blocked dates, week-type ↔ division gender, home venue from the home club's pool).
- 2-opt soft-constraint optimisation to spread out each team's matches and balance the gap between home and away legs — with per-league tunable weights.
- Incremental re-run that locks Confirmed fixtures and reshuffles only the rest.
- Scheduling diagnostics — an on-demand "diagnose" dry-run (no persistence) that reports, per division, matches required vs placed and the eligible-week count, and lists any pairings the scheduler couldn't place — so admins can see why a season won't fully schedule.
Match lifecycle
Proposed → Confirmedonce both clubs accept (LeagueAdmin can force-confirm), or→ Rejected.- Postpone a Confirmed match back into the pool for re-scheduling.
- Record results and walkovers; standings are derived live per division (played/won/drawn/lost, rubbers for/against/diff, points) with a head-to-head tiebreak.
- Club admins get a "my club's matches" view to act on their own fixtures.
- Calendar feed (iCal) — subscribe a calendar app to a club's, league's or team's fixtures via a tokenised, login-free
.icsURL.
Notifications
- Domain events (invites, membership responses, match confirmations/rejections/postponements) plus auth emails (verification, password reset) are written to an outbox and delivered by a background sender. Set
Smtp:Hostto deliver over SMTP (MailKit); with no host configured it falls back to a logging sender, so dev and tests need no mail server. - Every delivered email carries a version footer (
smash-dates <version>) sourced from the same build-stamped value asGET /api/version, so a received mail is traceable to the build that sent it.
Players & registrations
- Players are global, club-managed roster records (no login); clubs link them as Member or Visitor.
- Discipline registrations (
Level/Mixed) are scoped to(player, club, league): a club registers a Member, the league confirms, and at most one club holds a player's discipline per league. - Transfers move a confirmed registration between clubs — the receiving club requests, the releasing club and the league both approve. See ADR 0003.
- Team squads — assign players to a team, with eligibility enforced: the player must be confirmed for the team's discipline at the club, with a matching gender (squads can be built before the team is entered in a season).
Club night (pegboard)
- A live pegboard session replaces the physical club-night board: track who turned up, a fair waiting queue (with each player's wait time), courts you add/remove on the fly, and the games on them — singles, level doubles, mixed, or a "funny" (e.g. 3+1) — with a required winner, optional score, and per-night played/won stats.
- Adding players defaults to picking from the club's existing roster; an occasional walk-in is registered as a visitor (a real player saved to the club, so they're selectable next time), which the host can do without being a club admin (ADR 0010).
- Fill a free court three ways — manual, suggest, or auto-fill — balancing longest-waiting, valid gender makeup, partner/opponent variety, and player grade. A makeup that breaks the game type's rule warns but never blocks the host.
- Schedule sessions ahead of time — plan the next few weeks with an optional start time, duration and venue; a host opens a scheduled session when the night begins (
Scheduled → Open → Closed). Many can be queued, but only one runs at a time. See ADR 0009. The live board header shows the club's name. - Run by a new per-club Session Host role (or any club admin / SystemAdmin); any signed-in user can watch. The board streams live to every viewer over Server-Sent Events — see ADR 0004. Viewers get the same board read-only (host controls hidden), and a closed session stays viewable as a read-only history with each attendee's final stats — tap any player for their full match breakdown (every game, partners/opponents, result, score) and a court-time vs waiting-time split.
Interface
- Light / dark theme — follows the OS preference by default, with a toggle that persists an explicit choice (no flash on load).
- Action feedback — create / invite / grant actions confirm with a dismissable toast (announced to screen readers); inline actions disable while in flight to prevent double-submits.
- Version footer — a global footer shows the running build's version (read live from
GET /api/version) on every page, authenticated and public alike.
Images live in
docs/screenshots/. The league and club pages organise their sections into tabs (the active tab is kept in the URL).
The admin entry point: the league page, tabbed into Divisions · Seasons · Clubs · Players · Scheduler.
Configure a season's weeks, assign teams to divisions, then generate the fixture list.
Confirm, reject or record results on fixtures; standings update live with colour-coded statuses.
The club page, tabbed into Teams · Venues · Players · Matches · Blocked dates · Admins (Teams shows squads).
The Sessions tab opens a club night now or schedules one ahead of time (grouped into Upcoming / Live / Past); the full-screen board — headed with the club's name — tracks courts, live games (with sides + type), and a fair waiting queue, streamed live to every viewer over SSE.
Import clubs, teams, venues, season entries or club players from a CSV — partial import with a per-row result and a downloadable template.
The league confirms discipline registrations and adjudicates transfers between clubs.
A signed-in user edits their display name, changes their own password and reviews the read-only list of role grants they hold.
A logged-out, read-only view of a league's standings and fixtures at /public — PII-free.
Every screen supports light and dark, following the OS preference with a persisted toggle.
Two ways to run it: Development — from a clone, with the .NET/Node toolchain and a hot-reload loop; or Production — self-host the published container image with one command, no checkout or build.
Prerequisites: .NET 10 SDK, Node.js + npm, Docker (for Postgres / integration tests). Clone the repo, then start Postgres and install the client deps:
docker compose up -d # Postgres on localhost:5432 (docker-compose.yml)
cd ClientApp && npm install && cd .. # first run onlyRun the dev loop in two terminals, same origin (no ng serve, no proxy):
cd ClientApp && npm run watch # Terminal 1 — rebuild the Angular bundle on change
dotnet run # Terminal 2 — API + serves the client + SPA fallbackOpen http://localhost:5079 and register — the first account becomes the SystemAdmin. The connection string is read from ConnectionStrings:Postgres (default localhost:5432, postgres/postgres); override with the ConnectionStrings__Postgres env var.
To exercise the whole container locally instead, build and run the image:
docker build -t smash-dates .
docker run -p 8080:8080 \
-e "ConnectionStrings__Postgres=Host=host.docker.internal;Port=5432;Database=smash_dates;Username=postgres;Password=postgres" \
smash-datesOne command downloads a production docker-compose.yml + Caddy config, generates a database password, and starts the stack — PostgreSQL + the published image + Caddy (which terminates HTTPS) — then docker compose up -d:
# bash / Linux / macOS
curl -fsSL https://raw.githubusercontent.com/aamcatamney/smash-dates/main/scripts/install.sh | bash# Windows / PowerShell
irm https://raw.githubusercontent.com/aamcatamney/smash-dates/main/scripts/install.ps1 | iexIt writes docker-compose.yml, Caddyfile and .env into ./smash-dates and brings the stack up. By default it serves https://localhost with a self-signed cert; for a trusted certificate, point a domain's DNS at the host (ports 80/443 open) and pass it in:
curl -fsSL .../install.sh | DOMAIN=league.example.com bashHTTPS is required, not optional: the app's auth/antiforgery cookies are Secure, so cookie-issuing endpoints (register/login) only work over HTTPS — Caddy provides it and forwards X-Forwarded-Proto, which the app honours (UseForwardedHeaders). Register the first account to become the SystemAdmin. (Self-hosting by hand instead? The compose file + Caddyfile live in deploy/.)
Make the image public: the published package
ghcr.io/aamcatamney/smash-datesmust be public for the anonymous pull above to work — set it in the repo's Packages →smash-dates→ Package settings → Change visibility. Otherwisedocker login ghcr.iowith a token that hasread:packagesfirst.
Email delivery: verification and password-reset links (and all other notifications) only reach users once SMTP is configured — set the
Smtp__*env vars in.env(at minimumSmtp__Host). With no host set, mail is written to the application log instead. See Email configuration for every setting.
dotnet test # backend (integration tests need Docker for Testcontainers)
cd ClientApp && npm test # frontendCreate Migrations/Scripts/NNNN_description.sql (zero-padded sequence). It's picked up as an embedded resource and applied in name order on next startup.
Releases are CalVer: vYYYY.M.MICRO (e.g. v2026.5.0), where MICRO is a per-month counter that resets at the start of each month.
Every merge to main runs the Release workflow, which:
- computes the next version from existing tags,
- builds and pushes the container image to GHCR — tagged
:YYYY.M.MICRO,:YYYY.M, and:latest, with build provenance + SBOM attestation, - creates the git tag and a GitHub Release with auto-generated notes.
Put [skip release] in the merge commit message to skip a release.
docker pull ghcr.io/aamcatamney/smash-dates:latestA running container exposes GET /health (liveness) and GET /api/version (the CalVer version stamped in at build).
Notifications (membership invites/responses, match updates) and auth emails (verification, password reset, resend) are written to an outbox and delivered by a background sender. Delivery is opt-in: with no Smtp__Host set the app uses a logging sender — every message is written to the application log instead of being sent — so development and the test suite need no mail server. Set Smtp__Host to switch to real SMTP delivery (MailKit).
Settings bind from the Smtp configuration section; as environment variables they take the double-underscore form Smtp__<Name> (e.g. in the deploy .env).
| Variable | Default | Notes |
|---|---|---|
Smtp__Host |
(unset) | SMTP server hostname. When empty/unset, the app falls back to the logging sender (mail written to the application log; no mail server needed for dev/tests). Setting it enables SMTP delivery. |
Smtp__Port |
587 |
SMTP port. |
Smtp__Username |
(unset) | Auth username. If unset, the sender connects without authenticating. |
Smtp__Password |
(unset) | Auth password, used with Smtp__Username. |
Smtp__FromAddress |
no-reply@smash-dates.local |
Envelope/From address on outbound mail. |
Smtp__FromName |
Smash Dates |
Display name paired with the From address. |
Smtp__UseStartTls |
true |
Negotiate STARTTLS when the server advertises it. Set false only for a plaintext relay (e.g. a local test server). |
Every delivered email ends with a footer line smash-dates <version>, sourced from the same build-stamped value as GET /api/version, so a received mail is traceable to the build that sent it.
| Layer | Choice |
|---|---|
| Backend | .NET 10, ASP.NET Core Minimal APIs (one endpoint per file) |
| Data | PostgreSQL via Dapper + Npgsql (repository pattern, no EF Core) |
| Migrations | DbUp — embedded SQL scripts applied idempotently on startup |
| Auth | Cookie authentication + antiforgery; BCrypt password hashing; Data Protection keys persisted in Postgres |
| Background work | BackgroundService hosted services (season transitions, schedule generation, notification delivery, auth-token cleanup) |
| Real-time | Server-Sent Events for the live club-night pegboard (in-process pub/sub) |
| Frontend | Angular 21 (standalone components, signals, reactive forms) + Tailwind CSS, served same-origin as static files |
| Tests | xUnit v3 + Testcontainers (integration, real Postgres) for the backend; Vitest for the frontend |
| Packaging | Single multi-stage Docker image (Angular production bundle + .NET publish) |
Architecture decisions are recorded in docs/adr/ and the domain language in CONTEXT.md.
MIT — see LICENSE.













