Daily guessing game: five Steam games per day, guess each game’s review-count bucket. Monorepo contains a Spring Boot backend and a Next.js frontend.
- IStoreService/GetAppList:
https://steamapi.xpaw.me/#IStoreService/GetAppList - Steam Storefront:
https://store.steampowered.com/api/appdetails - Steam Reviews:
https://store.steampowered.com/appreviews
- Java 21, Spring Boot 3.5
- Gradle (wrapper included)
- PostgreSQL, JPA/Hibernate
- Quartz for scheduled jobs
- Actuator for health/metrics
- Java 21 (required)
- PostgreSQL 16+ (local or Docker)
Default JDBC URL in backend/src/main/resources/application.yml is:
jdbc:postgresql://localhost:5432/postgres
Create database and a least-privileged user:
-- From psql connected as a superuser (e.g. postgres):
CREATE DATABASE postgres;
CREATE ROLE steam5_user WITH LOGIN PASSWORD 'steam5_password';
GRANT CONNECT ON DATABASE postgres TO steam5_user;
\c steam5
GRANT USAGE ON SCHEMA public TO steam5_user;
GRANT CREATE, USAGE ON SCHEMA public TO steam5_user;Alternatively, run Postgres via Docker:
docker run --name steam5-pg -e POSTGRES_DB=postgres -e POSTGRES_USER=steam5_user -e POSTGRES_PASSWORD=steam5_password -p 5432:5432 -d postgres:16- Via IDEA, right click "steam5_db" in database browser
- Export via pg_dump
- Export as tar and with "copy" statement
- Rename generated .sql file to .sql.gz
- Upload to coolify "Import Backup"
- Add
--cleanto import command - Run import
If first time, login to container and setup database and role, via above sql steps.
You can override properties with environment variables:
SPRING_DATASOURCE_URL(e.g.jdbc:postgresql://localhost:5432/steam5)SPRING_DATASOURCE_USERNAME(defaultsteam5_user)SPRING_DATASOURCE_PASSWORD(defaultsteam5_password)SERVER_PORT(default8080)
The dev profile is active by default. See backend/src/main/resources/application.yml.
- Windows (PowerShell):
cd backend; .\gradlew.bat bootRun - macOS/Linux:
cd backend && ./gradlew bootRun
Run tests:
cd backend && ./gradlew testQuartz jobs (see backend/src/main/java/org/steam5/job):
SteamAppListJob,SteamAppReviewsJob,SteamAppDetailJob: periodic ingestion and refreshReviewGameStateJob: generates daily picks (once per day)BlurhashScreenshotsJob,BlurhashAvatarJob: compute BlurHash placeholders asynchronously
Jobs can also be triggered ad-hoc by the application (e.g., after daily picks generation or user profile update). Respect rate limits; on any Steam 429 the jobs abort early.
GET /api/review-game/todayand/today/details: daily picks and detailsPOST /api/review-game/guess: submit a guessGET /api/review-game/buckets: bucket labels for UIGET /api/leaderboard/todayand/leaderboard: leaderboards- Auth:
/api/auth/steam/*(OpenID),/api/auth/me,/api/auth/logout - Actuator:
/actuator/*(includes/actuator/quartzin dev)
Security: token-based auth via Steam login. See frontend/app/api/auth/* and backend/web/AuthController.
-
Index
- Actuator root: lists all exposed endpoints.
-
Health
- Health: overall and detailed status.
-
Metrics (JSON)
- Metrics index: all metric names.
- Cache metrics (Caffeine with
recordStats()):- cache.requests (all)
- Per cache name (examples):
- Hits/misses:
GET /actuator/metrics/cache.gets?tag=cache:review-game - Requests:
GET /actuator/metrics/cache.requests?tag=cache:one-day - Puts:
GET /actuator/metrics/cache.puts?tag=cache:review-game - Evictions:
GET /actuator/metrics/cache.evictions?tag=cache:review-game - Size:
GET /actuator/metrics/cache.size?tag=cache:review-game
- Hits/misses:
- Tip: add
&tag=cacheManager:cacheManagerif you have multiple managers.
- HTTP server metrics:
GET /actuator/metrics/http.server.requests?tag=uri:/api/review-game/today
-
Prometheus (text)
- Prometheus scrape
- Search for series like
cache_gets_total,cache_requests_total,cache_evictions_totalwith tagscache="review-game"etc.
-
Environment and logging
- Environment
- Loggers (lists all loggers and their levels)
-
Beans
-
Quartz (scheduler)
Notes
- You’ll only see cache metrics after endpoints using
@Cacheableare exercised. - Actuator runs on its own management port (
MANAGEMENT_SERVER_PORT, default8081) and is protected by HTTP Basic auth. In dev the credentials default tometrics/metrics;/actuator/healthis the only endpoint that remains publicly reachable. In production (coolify) the same scheme applies and the defaults must be overridden viaMETRICS_USERNAME/METRICS_PASSWORD— the backend logs a startupWARNif the dev defaults are still in use. To narrow exposure further, adjustSecurityConfig/EndpointRequest.
A self-contained Prometheus + Grafana stack lives in monitoring/ and scrapes
the backend's /actuator/prometheus endpoint over HTTP basic auth.
-
Stack: Prometheus 3.5.1, Grafana 12.3.1 (image tags pinned in
monitoring/.env.example). -
Dashboards: 5 provisioned dashboards (JVM, HTTP server, HikariCP, Caches, Quartz jobs).
-
Local quick start (with the backend already running on
MANAGEMENT_SERVER_PORT=8081):cd monitoring cp .env.example .env docker compose --env-file .env up -d -
URLs: Prometheus at http://localhost:9090, Grafana at http://localhost:3001 (default credentials
admin/admin— change in.env).
See monitoring/README.md for the full reference, environment variable table,
cross-platform notes (macOS vs. Linux host.docker.internal), Coolify production deployment guide,
and troubleshooting.
- Next 15, App Router, TypeScript
- Local fonts via
next/font/local(Monaspace Krypton & Neon)- To keep payload small we only ship Regular (400) and Bold (700) for each
- Neon is not preloaded; toggled via the UI will load it on demand
- BlurHash placeholders for screenshots and avatars
- Image host allowlist in
next.config.ts
Dev:
cd frontend
npm i
npm run dev- Create a migration for any schema change (never change past migrations).
- Keep entities and repositories aligned with the schema.
- Add tests for the ingest job and API controllers.
guessesdate-range queries (for examplefindAllBetween,findSeasonStats,findSeasonDates) rely onidx_guesses_game_date.- Leaderboard reads (
/api/leaderboard/all,/monthly,/weekly?floating=true,/season) are served from materialized views (mv_leaderboard_all_time,mv_leaderboard_monthly,mv_leaderboard_weekly,mv_leaderboard_season— seebackend/src/main/resources/db/mv-leaderboard-*.sql) instead of aggregatingguesseson every request. This moves the expensiveGROUP BY steam_idaggregation off the request path and gives every app instance a single shared, consistent read model — unlike per-instance Caffeine caching (leaderboard-static), which each instance populates independently and can disagree for up to its TTL after a restart or scale-out event. Caffeine still sits in front of the MV reads as a last-mile cache (10-minute TTL), so repeated identical requests avoid even the (now much cheaper) MVSELECT. The non-floating/weeklyvariant (previous Monday-Sunday week) is not MV-backed — its window doesn't match the MVs' rolling/current-window definitions — and remains a livefindAllBetweenquery, as before.- Staleness is bounded by refresh cadence, not by the Caffeine TTL: each MV is refreshed by its own
Quartz job (
LeaderboardRefreshJob, one per type viaJobDataMap, gated byjobs.leaderboard-refresh-<type>.enabled) on a nightly cron (00:40/00:42/00:44/00:46 UTC for all-time/monthly/weekly/season respectively, staggered afterseasons-finalizerat 00:25 so season boundaries are settled) plus, for all-time/monthly/weekly only, an additional 10-minute interval trigger matching theleaderboard-staticcache TTL. The season MV intentionally has no intraday trigger — its window depends on season rollover timing, not intraday freshness. - Zero-touch by default, no manual
psqlstep required:LeaderboardMvBootstrapConfig(anApplicationRunner, gated byapp.leaderboard-mv.bootstrap.enabled, defaulttrue) creates any missing MV or unique index at application startup, reading the samedb/mv-leaderboard-*.sqlfiles an operator would otherwise apply by hand — via a raw autocommit JDBC connection, not Hibernate'sddl-auto(CREATE INDEX CONCURRENTLYstill can't run insideddl-auto's transaction, which is why this is a dedicated bootstrap step rather than a JPA-managed table). It also runs a one-time initialREFRESH(and records it inleaderboard_refresh_state, mirroringLeaderboardRefreshService's own bookkeeping) for any view it finds unpopulated — whether just created or already present but never refreshed — so a fresh view is queryable and shows a "Last updated" timestamp immediately, instead of waiting for whichever scheduled refresh job fires next. That wait matters most for the season MV, which has no intraday trigger (only a once-daily 00:46 UTC cron): without this, every request in that window — including a Next.js build-time prefetch of/review-guesser/leaderboard/season— would hitmaterialized view "mv_leaderboard_season" has not been populated.jobs.leaderboard-refresh-*.enabledalso default totrue, soLeaderboardRefreshServicekeeps each view fresh afterward — self-healing viapg_matviews.ispopulatedif it's ever found unpopulated again (falls back to a plain, non-concurrentREFRESH, then usesCONCURRENTLY). Setapp.leaderboard-mv.bootstrap.enabled=falseif a DBA wants to controlCREATE INDEX CONCURRENTLYtiming manually on a very large production table instead — in that case the views stay unpopulated (and the "not populated" error above is expected) until a manualREFRESHor the corresponding scheduled job runs. - Each successful refresh also writes a row to
leaderboard_refresh_state(an ordinary Hibernate-managed table, unlike the MVs themselves), whichLeaderboardControllerexposes via anX-Leaderboard-Refreshed-Atresponse header (ISO-8601) on/monthly,/weekly?floating=true,/season, and/all— omitted until the first refresh completes. The frontend renders this as a localized "Last updated" line below the all-time/season/weekly-floating leaderboards. - Every
u.*column in each MV'sSELECTis listed explicitly in itsGROUP BY(not justu.steam_id), so Postgres never invokes its functional-dependency-on-primary-key optimization for them. That optimization, if relied on, records a catalog dependency from the view onto theusers_pkeyconstraint itself — which then blocksALTER TABLE users DROP CONSTRAINT users_pkey(including the onepg_restore --cleanissues when reloading a backup) unlessCASCADEis added. If your database still has MVs created before this fix, they carry that old dependency and won't self-correct — drop and let the app's bootstrap recreate them:Run this once (before aDROP MATERIALIZED VIEW IF EXISTS mv_leaderboard_all_time, mv_leaderboard_monthly, mv_leaderboard_weekly, mv_leaderboard_season CASCADE;
pg_restore --clean, or any other operation touchingusers_pkey), then restart the app —LeaderboardMvBootstrapConfigrecreates and immediately populates all four. - A hook to trigger a season-MV refresh directly from
SeasonService#finalizeSeason/#ensureSeasonForDatewas considered but intentionally not added: the season job's 00:46 UTC cron already runs afterseasons-finalizer(00:25), so the extra coupling wasn't justified. Revisit if season rollover timing ever needs tighter (sub-cron-interval) correctness. - Validate the improvement empirically against the existing Grafana
steam5-postgresdashboard (query latency/throughput on theguessestable) andsteam5-cachesdashboard (Caffeine hit rate forleaderboard-static) before/after rollout.
- Staleness is bounded by refresh cadence, not by the Caffeine TTL: each MV is refreshed by its own
Quartz job (
mv_hardest_gamesbacksGET /api/stats/game/hardestthe same way — seebackend/src/main/resources/db/mv-hardest-games.sql. Refreshed once daily only (00:48 UTC,jobs.leaderboard-refresh-hardest-games.enabled, defaulttrue) — no intraday trigger, since game-difficulty rankings change slowly. Also auto-bootstrapped, drop-listed forpg_restore --clean(seeleaderboard-mv-maintenance.sql), and exposes the sameX-Leaderboard-Refreshed-Atheader/"Last updated" UI as the other four.- Profile history lookup uses
(steam_id, game_date, round_index)viafindBySteamIdOrderByGameDateDescRoundIndexAsc. UserRepository's @mention-autocomplete search (findTop10ByPersonaNameContainingIgnoreCase..., backingGET /api/users/search) relies onidx_users_persona_name_trgm, apg_trgmGIN index onUPPER(persona_name)— a plain B-tree can't serve a leading-wildcard, case-foldedLIKE. Not auto-bootstrapped like the leaderboard MVs' indexes, so it must be applied manually.SteamAppReviewsRepositoryrandom-pick methods use a two-phase CTE +NOT EXISTSpattern to avoid random sorting on the full table;idx_reviews_eligibleis an optional partial index for very large review datasets.backend/src/main/resources/db/all-indexes.sqlis the single consolidated, manual-apply index reference for prod — every index that isn't auto-created by Hibernate ddl-auto or the leaderboard-MV bootstrap, includingidx_users_persona_name_trgmandidx_reviews_eligibleabove, plus a set of join/detail-table indexes (steam_app_genre/category/developer/publisher,price,screenshots, two more onsteam_app_reviews) that existed under the old Flyway-managed schema and were never recreated after the switch to ddl-auto. Every statement usesCREATE INDEX CONCURRENTLY IF NOT EXISTS, so the whole file is safe to re-run, in part or in full, at any time — see its header comment for the one edge case that isn't safe (anINVALIDindex left behind by an interrupted priorCONCURRENTLYrun).GuessRepositorymulti-scan CTE methods (findUsersByPerfectDays*,findUsersByDailyTimeDiff*) are currently service-cached; if data volume grows, prioritize window-function rewrites.GuessRepository#leaderboardAllTime(a JPQL query, distinct from the materialized-view path above) currently has no callers — the all-time leaderboard read path now goes entirely throughmv_leaderboard_all_time. Kept as-is rather than deleted in this pass; a future cleanup could remove it if it stays unused.
See LICENSE.