Skip to content

About

A private, mobile-first CS2 Major Pick'Em companion app for IEM Cologne 2026.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

HOTLINE

A CS2 Major Pick'Em companion for a small private group. Members log in with Steam (or play locally — no Steam needed), set/mirror their picks, and compete on a shared leaderboard scored on Valve's own weighting. Built generically so it can be re-pointed at future Majors.

First season, IEM Cologne 2026, is complete and archived. The next Major, PGL Singapore 2026, is already seeded in the event registry and will stand up automatically when it comes around.

Next.js (App Router) + TypeScript + Prisma/SQLite. Deploys as a single container on the phattvip network behind SWAG at pickems.phatt.vip. The responsive installable PWA is the entire mobile story (no native apps).

📚 Knowledge base → docs/ — architecture, the next-Major runbook, operations, and hard-won gotchas. Start there before changing the system or standing up a new Major. Keep it true: update the relevant doc in the same PR. If you're picking this up cold — see docs/CONTINUITY.md first.

🔒 Authorship gate — every commit on phatt-picks must be authored by phattbeats with zero Co-authored-by trailers. See docs/AUTHORSHIP.md. Enforced locally by a commit-msg hook (installed by npm install) and in CI by .github/workflows/authorship-check.yml.

What's inside

  • Per-stage picks — Swiss stages as 2 / 6 / 2 buckets, plus one interactive QF→SF→GF playoff bracket you place at once (tap a winner, they advance).
  • Steam mirror — pull your live Valve Pick'Em and (optionally) push locally-set picks back up.
  • Shared leaderboard — scored on Valve's own weighting; picks hidden until each stage locks.
  • Stage Reveal & Wrapped — per-stage reveal boards and a click-through "Stage Wrapped" recap.
  • The Bleachers — drop a fixed reaction stamp on someone's revealed pick; anonymous in the moment, unmasked when the stage resolves.
  • Playoff Spotlight — per-team narrative + highlight, with live Polymarket implied odds.
  • Challenge coins — a tiered collectible minted per Major you played, once the Grand Final crowns a champion.
  • Notifications inbox — an in-app bell for reactions, reveals, and other activity, alongside push.
  • PWA + Web Push — installable, with opt-in 24h/1h pre-lock reminders.
  • Multi-Major by design — an event registry + clock-derived lifecycle re-points the app at the next Major from committed config (PGL Singapore 2026 is already seeded).

Getting started (local dev)

NODE_ENV=development npm install --include=dev --cache "$(pwd)/.npm-cache"
DATABASE_URL="file:./dev.db" npx prisma generate
DATABASE_URL="file:./dev.db" npx prisma db push
npm run dev   # http://localhost:3000

A gitignored dev .env (SQLite + dummy secrets) is already in the repo root.

Environment variables

See .env.example. Summary:

Var Purpose
DATABASE_URL SQLite path — use an absolute appdata path in prod (not a FUSE share).
NEXTAUTH_URL Public base URL (https://pickems.phatt.vip). Used for OpenID return + invite links.
NEXTAUTH_SECRET Session-JWT signing secret. openssl rand -base64 32.
STEAM_API_KEY One app key, server-side only, never sent to the client.
AUTH_CODE_ENCRYPTION_KEY 32-byte hex; encrypts per-user Valve auth codes at rest.
WRITE_ENABLED "true" enables the stage-batched write-back to Valve (destructive — overwrites the owner's live picks). Defaults false; leave off for normal play, flip on only for deploy-smoke or a deliberate write-back.
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY Web Push keypair. Generate: npx web-push generate-vapid-keys.
VAPID_SUBJECT mailto: contact for push.
PRELOCK_REMINDERS_DISABLED (optional) 1/true turns OFF the in-process pre-lock reminder scheduler. It is ON by default with no env required.
STAGE_LOCKS_JSON (optional) override for the reminder cutoffs; defaults to the committed COLOGNE_LOCK_SCHEDULE when unset.

PWA install & push reminders

HOTLINE is an installable PWA (public/manifest.json + public/sw.js). Installing is required for push on iOS — opt-in pre-lock reminders (a 24-hour and a 1-hour warning before each stage locks) are delivered via Web Push.

  • iPhone: tap Share → Add to Home Screen, then open HOTLINE from the new icon. iOS only delivers Web Push to an installed, home-screen-launched PWA — this step is mandatory there.
  • Android / desktop: use the browser's "Install app" / "Add to Home screen" option. Push works without installing, but installing is recommended.

Enable reminders under You → Pick-lock reminders, then Send a test reminder to confirm.

The real 24h/1h reminders are fired by an in-process scheduler (src/instrumentation.ts, a ~5-min tick) — no external cron/sidecar. The scheduler is ON by default; set PRELOCK_REMINDERS_DISABLED=1 to turn it off. Cutoffs come from the committed COLOGNE_LOCK_SCHEDULE; STAGE_LOCKS_JSON is an optional override. Pure scheduling logic is in src/lib/prelock-reminders.ts and src/lib/notify-core.ts.

Inviting friends

Every player has a stable invite link (/join/<code>). Share it from You → Invite friends. The landing page onboards a friend in one tap — local play needs no Steam account.

Deployment (owner wires SWAG)

Images are published to ghcr.io/phattbeats/phatt-picks. Pull :latest to track main, or pin to a release tag for rollback:

docker pull ghcr.io/phattbeats/phatt-picks:latest      # always-current main
docker pull ghcr.io/phattbeats/phatt-picks:vX.Y.Z      # pinnable release (e.g. v0.1.0)
docker pull ghcr.io/phattbeats/phatt-picks:X.Y         # latest patch in that minor line

Cutting a release = push a vX.Y.Z git tag; CI publishes :vX.Y.Z, :X.Y, and updates :latest.

Build the container to join phattvip with the DB on a non-FUSE appdata path. SWAG proxy-conf the owner installs:

# swag: pickems.subdomain.conf
server {
    listen 443 ssl;
    server_name pickems.*;
    include /config/nginx/ssl.conf;
    location / {
        include /config/nginx/proxy.conf;
        resolver 127.0.0.11 valid=30s;
        set $upstream_app major-companion;   # must match the container name on phattvip
        set $upstream_port 3000;
        proxy_pass http://$upstream_app:$upstream_port;
    }
}

Outcome ingestion cadence

POST /api/outcomes/ingest is event-gated, not poll-driven. It returns source: "none", reason: "no-locked-unresolved" whenever the live layout has zero locked stages with missing StageOutcome rows — i.e. pre-event. A scheduler/cron pointed at this route MUST honor that signal and back off until stage locks roll over; in that state the route makes zero outbound calls to Liquipedia. The persisted SourceState.lastCallAt enforces the 1-req-per-30s Liquipedia min-interval across container restarts, so even a restart loop cannot violate the API terms. Regression: node scripts/verify-outcomes-gate.ts.

Attribution

Match results / outcomes: Liquipedia (CC-BY-SA 3.0). Team logos: ByMykel/CSGO-API, falling back to self-hosted slugs / monograms.

About

A private, mobile-first CS2 Major Pick'Em companion app for IEM Cologne 2026.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages