- My World Cup App
A lightweight Go web application that displays the FIFA World Cup 2026 (Canada/Mexico/USA) group standings, match results, and knockout stage, plus curated links to official FIFA resources. Data is fetched live on startup and on demand, with no database required.
- Group standings — computed on the fly from match results (played, won, drawn, lost, goals, goal difference, points).
- Knockout stage — a graphical bracket (Round of 32 through the Final, plus the match for third place) with connector lines and winner highlighting, alongside round-by-round detail tables with date/venue/result.
- Match list — every fixture with date, round, group, venue, and result, filterable by round, group, and/or team via query parameters (combinable).
- Live data refresh — data is fetched from openfootball/worldcup.json on startup and via the "Update data" button; an embedded snapshot is used as a fallback if the live source is unreachable.
- Statistics — top scorers and overall team records (played/won/drawn/lost/goals/points) aggregated across group and knockout stage matches.
- Dark / light theme — toggle persisted in the browser via
localStorage. - Official FIFA links — stadiums, teams, standings, articles, scores & fixtures, official match ball, posters, mascots, Club World Cup 2025, and the official FIFA Sound playlist; team names, stadiums, and host cities across the app link to their official fifa.com pages.
- Observability —
/healthzhealth check and a Prometheus-compatible/metricsendpoint (HTTP request counts/latency, data refresh outcomes).
| Tool | Minimum Version | Required For |
|---|---|---|
| Go | 1.25 | Building/running/testing locally |
| Make | any | Running the Makefile shortcuts |
| Git | any | Cloning the repository, contributing |
| Docker | 24+ | Building/running the container image |
| Docker Compose | v2 (plugin) | Local containerized run (docker compose) |
| Docker Buildx | v0.10+ | Multi-arch image builds/push (make docker-build-multiarch, make docker-push) — bundled with Docker Desktop and recent Docker Engine installs |
| Helm | 3.x | Installing/linting the Helm chart |
| helm-docs | 1.x | Regenerating helm/my-world-cup-app/README.md |
| kind | v0.20+ | Loading the local image into a local kind cluster (make kind-load) |
Run make check-deps to verify which of these are installed on your machine; it prints installation instructions for anything missing.
See also CONTRIBUTING.md for the contribution workflow.
Run make help (or just make, since help is the default goal) to print this list from the terminal.
| Target | Description |
|---|---|
make help |
Show the list of available targets |
make check-deps |
Verify required development/runtime tools are installed |
make run |
Run the application locally |
make build |
Build the server binary into bin/ |
make test |
Run all tests |
make test-coverage |
Run tests with a coverage report |
make fmt |
Format source code |
make fmt-check |
Check source code formatting |
make vet |
Run go vet |
make tidy |
Tidy go.mod/go.sum |
make check |
Run formatting, vet, and tests (fmt-check + vet + test) |
make docker-build |
Build the Docker image (tagged with the VERSION file's version) |
make docker-up |
Start the application via Docker Compose |
make docker-down |
Stop and remove the Docker Compose services |
make docker-logs |
Tail the application container logs |
make docker-buildx-setup |
Create (or reuse) the Docker Buildx builder used for multi-arch images |
make docker-build-multiarch |
Build a multi-arch image (linux/amd64 + linux/arm64, runs on Linux and macOS/Intel+Apple Silicon) without pushing, to validate the build for both platforms |
make docker-push |
Build and push a multi-arch image (linux/amd64 + linux/arm64); interactively prompts for registry username, password/token, and repository name |
make kind-load |
Load the local Docker image into the kind cluster (KIND_CLUSTER, default kind-multinodes) |
make helm-sync-version |
Write the VERSION file's version into the Helm chart's appVersion (helm/my-world-cup-app/Chart.yaml) |
make helm-lint |
Lint the Helm chart |
make helm-docs |
Sync appVersion from VERSION, then regenerate the Helm chart README (helm/*/README.md) via helm-docs |
make helm-install |
Install/upgrade the app into Kubernetes via Helm (namespace: NAMESPACE, default app name) |
make helm-uninstall |
Uninstall the Helm release from Kubernetes |
make clean |
Remove build artifacts |
make run
# or
PORT=8080 go -C app run ./cmd/serverThen open http://localhost:8080.
make test # go test ./... -v
make test-coverage # with coverage reportmake build # outputs bin/my-world-cup-appmake docker-build # docker compose build
make docker-up # docker compose up -d --build
make docker-logs # tail logs
make docker-down # stop and removeThe container serves the app on PORT (default 8080), mapped to the host via docker-compose.yml.
Dockerfile builds a static, CGO-free binary, so the same build works unmodified on both linux/amd64 and linux/arm64 — covering Linux servers and macOS (both Intel and Apple Silicon, since Docker Desktop always runs Linux containers matching the host architecture) via Docker Buildx:
make docker-build-multiarch # build for linux/amd64 + linux/arm64 (validates only; multi-platform results can't be loaded into the local daemon)
make docker-push # build + push a multi-arch manifest to a registrymake docker-push interactively prompts for:
- Docker registry username
- Docker registry password or access token (hidden input, piped to
docker login --password-stdin— never passed as a CLI argument or left in shell history) - Repository name, e.g.
docker.io/<user>/my-world-cup-apporghcr.io/<user>/my-world-cup-app(the registry host is inferred from this to log in against the right registry; omit a host to default to Docker Hub) - Image tag (defaults to the version in the root
VERSIONfile if left blank)
make docker-push also runs make helm-sync-version first, so helm/my-world-cup-app/Chart.yaml's appVersion always matches the VERSION file before an image is published. Override the target platform list with DOCKER_PLATFORMS (default linux/amd64,linux/arm64), e.g. make docker-push DOCKER_PLATFORMS=linux/amd64.
Every image is also labeled org.opencontainers.image.version with the tag actually used (docker inspect <image> --format '{{.Config.Labels}}' to check), baked in via the Dockerfile's APP_VERSION build-arg.
A Helm chart is provided at helm/my-world-cup-app for Kubernetes deployment:
helm lint helm/my-world-cup-app
helm template my-world-cup-app helm/my-world-cup-app # render manifests locally
helm install my-world-cup-app helm/my-world-cup-app --set image.repository=<your-registry>/my-world-cup-app --set image.tag=<tag>The chart deploys a single Deployment + Service (ClusterIP by default), wires /healthz as the liveness/readiness probe, and pre-populates prometheus.io/scrape, prometheus.io/port, and prometheus.io/path pod annotations so a cluster Prometheus can auto-discover /metrics. Ingress and HPA are included but disabled by default (ingress.enabled / autoscaling.enabled in values.yaml).
Since a local kind cluster can't pull an image that only exists in your local Docker daemon, load it in first:
make kind-load # docker-build, then `kind load docker-image` into KIND_CLUSTER (default kind-multinodes)
make helm-install # installs/upgrades using the image tagged with the VERSION file's versionOverride the target cluster with make kind-load KIND_CLUSTER=<cluster-name> if yours isn't named kind-multinodes.
All configuration is via environment variables (see app/internal/config/config.go). There are no required variables — every one of them has a working default.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP listen port |
WORLDCUP_MATCHES_URL |
openfootball 2026/worldcup.json |
Match data source |
WORLDCUP_GROUPS_URL |
openfootball 2026/worldcup.groups.json |
Group assignments source |
WORLDCUP_TEAMS_URL |
openfootball 2026/worldcup.teams.json |
Team metadata source |
WORLDCUP_STADIUMS_URL |
openfootball 2026/worldcup.stadiums.json |
Stadium data source |
- Go 1.25 — standard library for the web layer (
net/http,html/template,encoding/json,embed); no web framework. - prometheus/client_golang — the only third-party dependency, used solely for
/metricsinstrumentation (app/internal/metrics). - Vanilla CSS (custom properties for theming) and vanilla JavaScript (no build step, no client framework).
- Docker / Docker Compose for containerized runs; a Helm chart for Kubernetes deployment.
go testfor unit and integration tests.
The application follows a clean, layered architecture:
app/cmd/server entrypoint: wiring, HTTP server lifecycle
app/internal/config environment-driven configuration
app/internal/models domain types (Team, Group, Match, Stadium, Standing, Tournament)
app/internal/data HTTP client, JSON parsing/normalization, thread-safe in-memory store
app/internal/services business logic: group standings, knockout grouping, match/stats queries
app/internal/handlers HTTP handlers, routing, template rendering
app/internal/metrics Prometheus instrumentation (HTTP requests, data refresh outcomes)
app/web/ embedded HTML templates and static assets (CSS/JS)
helm/ Helm chart for Kubernetes deployment
- Handlers depend on services and the data store, never the other way around.
- Services are pure functions operating on models, independent of HTTP or the data source — easy to unit test.
- Data owns fetching, parsing, and caching; it exposes a
StorewithSnapshot()andRefresh(). - No database: the
Storeholds the currentTournamentin memory behind async.RWMutex. Standings are recomputed from match results on every request rather than persisted.
flowchart TD
subgraph Startup
A[main.go] --> B[data.NewStore]
B --> C[Seed from embedded fallback JSON]
A --> D[Background goroutine: store.Refresh]
end
subgraph Refresh["Data Refresh (startup or /refresh)"]
D --> E[data.Client.Fetch]
E -->|success| F[parse: JSON -> models.Tournament]
F --> G[Store.set: update in-memory snapshot]
E -->|failure| H[Keep previous snapshot, log warning]
end
subgraph Request["HTTP Request"]
I[Browser] --> J[handlers.Router]
J --> K[Store.Snapshot]
K --> L[services: GroupStandings / KnockoutStage / AllMatches / Stats]
L --> M[html/template render]
M --> I
J -.-> P[metrics.ObserveRequest]
end
subgraph UI["Update data button"]
N[Click 'Update data'] --> O[POST /refresh]
O --> E
end
G -.-> Q[metrics.RecordRefresh]
H -.-> Q
Q --> R["/metrics (Prometheus exposition)"]
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | / |
pages.Home |
Dashboard: upcoming/recent matches |
| GET | /groups |
pages.Groups |
Group standings and results |
| GET | /knockout |
pages.Knockout |
Knockout stage bracket + round detail |
| GET | /matches |
pages.Matches |
Match list, filterable by round/group/team query params |
| GET | /stats |
pages.Stats |
Top scorers and team statistics |
| GET | /links |
pages.Links |
Official FIFA/Spotify links, stadiums |
| POST | /refresh |
refresh.Refresh |
Triggers a live data refresh |
| GET | /healthz |
healthz |
Health check (used by Docker/Helm) |
| GET | /metrics |
Prometheus handler | Prometheus metrics exposition |
| GET | /static/* |
embedded file server | CSS/JS assets |
my-world-cup-app/
├── app/ # Go module root — all application source
│ ├── cmd/server/main.go
│ ├── internal/
│ │ ├── config/
│ │ ├── models/
│ │ ├── data/
│ │ │ ├── client.go
│ │ │ ├── fallback.go
│ │ │ ├── fallback/ # embedded snapshot JSON (openfootball 2026 data)
│ │ │ ├── parser.go
│ │ │ └── store.go
│ │ ├── services/ # standings, knockout, matches, stats
│ │ ├── handlers/
│ │ └── metrics/ # Prometheus counters/histograms
│ ├── web/
│ │ ├── assets.go # go:embed directives
│ │ ├── templates/
│ │ └── static/{css,js}/
│ ├── go.mod
│ └── go.sum
├── helm/my-world-cup-app/ # Helm chart
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── VERSION # release version; see make helm-sync-version / docker-push
├── README.md
├── CHANGELOG.md
├── CONTRIBUTING.md
└── CLAUDE.md
- On startup, the app seeds itself from an embedded snapshot of the four openfootball JSON files (bundled at build time via
go:embed), so it can serve pages immediately. - A background goroutine performs an initial live refresh against the configured URLs.
- Clicking "Update data" in the UI (or
POST /refresh) triggers a synchronous live refresh. - If a live fetch fails (network issue, rate limit, etc.), the previous snapshot is kept and the failure is logged — the app never serves a broken or empty page.
Group tables (and the overall team statistics on /stats) are computed from played matches using standard football scoring (3 points for a win, 1 for a draw). Ties are broken by: points → goal difference → goals for → alphabetical order. This is a simplified tie-break; it does not implement FIFA's full head-to-head/fair-play rules.
/metrics exposes a dedicated Prometheus registry (app/internal/metrics), not the global default one:
http_requests_total{method,path,status}— request counts,statusbucketed as2xx/3xx/4xx/5xx.http_request_duration_seconds{method,path}— request latency histogram.data_refresh_total{outcome}— count of refresh attempts,outcome=success/failure.data_last_refresh_timestamp_seconds— Unix timestamp of the last successful refresh.
app/internal/data: JSON parsing/normalization tests, plus store tests covering successful refresh, failed refresh (previous snapshot retained), and fallback seeding.app/internal/services: standings, knockout grouping, top-scorer, and team-statistics computation tests with known inputs/expected outputs.app/internal/handlers: HTTP integration tests (httptest) covering every route (including/statsand/metrics), static asset serving, and refresh failure handling.
See CONTRIBUTING.md for the fork/branch/PR workflow and recommended editor setup.
Aecio dos Santos Pires
- Linkedin: https://www.linkedin.com/in/aeciopires/
- Site: http://aeciopires.com/
See LICENSE.







