diff --git a/bun.lock b/bun.lock
index 1a49289..8bff1b0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -38,12 +38,14 @@
"supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0",
"vaul": "^1.1.2",
+ "web-push": "^3.6.7",
"zod": "^4.0.15",
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+ "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
@@ -614,6 +616,8 @@
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
+ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
+
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -632,6 +636,8 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
+
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
@@ -662,6 +668,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
+ "bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
+
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="],
@@ -880,6 +888,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
+
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
@@ -888,6 +898,8 @@
"imask": ["imask@7.6.1", "", { "dependencies": { "@babel/runtime-corejs3": "^7.24.4" } }, "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg=="],
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@@ -1048,6 +1060,8 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+ "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
+
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1238,6 +1252,8 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
@@ -1404,6 +1420,8 @@
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
+ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
+
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@@ -1706,6 +1724,8 @@
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+ "web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
@@ -1924,6 +1944,8 @@
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+ "web-push/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
diff --git a/docs/side-bets.md b/docs/side-bets.md
new file mode 100644
index 0000000..96dacf3
--- /dev/null
+++ b/docs/side-bets.md
@@ -0,0 +1,84 @@
+# Side Bets โ Design Spec (v1, locked)
+
+**Status:** design locked, not yet built. Sequenced for Wave 3 (after player score reporting + the home "Upcoming" surface; ideally after web push for bet-open nudges).
+
+No real money, ever. This is an in-tournament engagement layer.
+
+## Currency
+- Each player gets **10 ๐ช tokens fresh at the start of every tournament**. No carryover, **no seasons** (this app has no season concept).
+- **Whole numbers only.**
+- **No hard bust.** If you hit 0 tokens with matches remaining, you get a **1-token "comeback" bet each remaining match** so you're never fully benched. Not abusable โ comeback stakes are smaller than the 10 you started with, so busting on purpose is strictly worse.
+
+## When you can bet
+- A match's book **opens when the match is *set*** (both teams populated โ status `ready`).
+- The book **closes the instant the admin *starts* the match** (status `started`) โ same moment walkouts/announcements play. No bets after start.
+- This dovetails with score reporting, which only begins once a match is `started`.
+
+## Bet type 1 โ Winner (social pool / parimutuel)
+The core, social bet. You bet against your friends, not the house.
+
+- Pick the winning team. Stake `1..(your tokens)`.
+- All stakes on the match form **one pot**. On finalize, everyone who picked the actual winner **splits the whole pot proportional to their stake**; wrong picks forfeit their tokens to the pot.
+- **Integer payout rule:** floor each winner's proportional share, then hand the leftover tokens out one-each to the largest fractional remainders (tie-break: larger stake, then earlier bet). Guarantees whole tokens and exact pot conservation.
+- **House floor:** a correct bet always returns **at least ~1.5ร the stake** even if nobody took the other side, so thin/late pools still feel worth it. (Tunable.)
+- **Upset dynamics are automatic:** betting the less-popular team pays more because fewer winners split a bigger pot โ no odds engine, no stats needed.
+- **Edge cases:**
+ - Everyone picked the same team โ no losers โ **all stakes refunded** (no action).
+ - Nobody picked the winner โ **everyone refunded**.
+ - Solo bettor โ the **house floor** applies (this is exactly what it's for).
+- **Live projected payout:** before close, show "if you're right you'd win ~X", ticking up via SSE as friends pile in. Final pot locks at match start.
+
+## Bet type 2 โ Overtime long shots (house-backed, fixed payout)
+NOT a pool โ you bet against a fixed multiplier, so including these does **not** thin the Winner pool. Cumulative "reaches at least N overtimes" tiers, keyed off the match `ot_count` field (the app tracks multiple OTs):
+
+| Bet | Wins if | Payout |
+|---|---|---|
+| Reaches OT | `ot_count โฅ 1` | **2ร** |
+| Reaches Double OT | `ot_count โฅ 2` | **4ร** |
+| Reaches Triple OT | `ot_count โฅ 3` | **8ร** |
+
+Each is an independent yes/no long shot; **each additional OT doubles the payout** (memorable rule, jackpot feel for triple OT). Multipliers are tunable once real OT frequency is observed.
+
+## Leaderboard & payoff
+- **Per-tournament Side Bets leaderboard**, ranked by token count; tie-break by number of correct bets (so a hoarder who never bet ranks below active players at the same count).
+- A few **badges** (global, like existing badges): e.g. "Called the Upset", "House Money", "Stone Cold Lock", "Degenerate" (most all-ins).
+
+## Data model (proposed โ finalize at build)
+- New `bets` collection: `{ tournament, match, player, market: "winner"|"ot1"|"ot2"|"ot3", pick (team id for winner), stake, status: pending|won|lost|refunded, payout }`.
+- **Settlement hooks into match finalization** (`finalizeMatch` / the `match` SSE): compute Winner pool splits + house-backed OT results, write payouts, update balances.
+- Per-(player, tournament) balance: start 10 โ stakes + payouts (materialized or derived).
+
+## UI/UX
+- Bettable matches surface on the home **Upcoming** carousel (Wave 2) + the match card/dock.
+- **Bet slip sheet:** choose market, stake slider, live projected payout.
+- Win/loss **reveal animation** on settle; the reaction rail already exists next to matches.
+- **Side Bets leaderboard screen** โ mirror the existing predictions leaderboard component.
+
+## Reuses / dependencies
+- SSE bus, the predictions leaderboard pattern, the badges system.
+- Depends on: score reporting (done), home Upcoming carousel (Wave 2), web push (Wave 2) for "betting's open on the Final" nudges.
+
+## Side Bets on the home screen (reserved slot)
+The Wave 2 home redesign **leaves a labeled slot for this** โ build it here later:
+- A compact **"Your Side Bets" card** near the top of the **Upcoming** section, showing your ๐ช token balance, number of open (unsettled) bets, and a link to the Side Bets leaderboard. Hidden if the tournament isn't bettable (no set-but-unstarted matches) or the user has no involvement.
+- On each card in the **Upcoming** carousel (matches that are `ready`, not yet `started`), a **"Bet" affordance** that opens the bet-slip sheet. This is the primary entry point โ you bet on matches that are about to start.
+- Once a match is `started`, its bets are locked; the card flips to showing the pool result live as it settles.
+- The redesign should reserve this vertical space and (in code) leave a clear `{/* SIDE BETS SLOT โ see docs/side-bets.md */}` marker where the card mounts, so wiring it in later is a drop-in.
+
+## Implementation notes for a fresh agent (build this cold)
+Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/side-bets.md` (this file) fully first, then:
+- **Server fns:** follow the `createServerFn(...).validator(zod).middleware([...]).handler(...)` + `toServerResult` pattern. Player-facing (non-admin) fns use `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])` and resolve the caller via `getPlayerByAuthId(context.userAuthId)` โ copy `toggleMatchReaction` in `src/features/matches/server.ts` as the template. All DB access is server-side via `pbAdmin`; collections have null rules.
+- **Settlement:** hook into `finalizeMatch` in `src/features/matches/server.ts` (the single match-finalization path โ admin `endMatch` and player `confirmMatchScore` both call it). When a match finalizes, settle its Winner pool (integer largest-remainder split + house floor) and the OT long shots. Emit an SSE event so balances/leaderboard update live.
+- **Bet open/close** is derived from match `status`: bettable while `ready`, locked at `started` (identical trigger to score reporting).
+- **Migration:** mimic an existing `pb_migrations/*_created_*.js` (e.g. `1784007613_created_predictions.js`) for the new `bets` collection โ plain `migrate((app)=>{...})` with an up + down. Teams collection id `pbc_1568971955`, players `pbc_3072146508`, matches `pbc_2541054544`, tournaments `pbc_340646327`.
+- **Client data:** use the `useServerQuery` / `useServerMutation` hooks (`src/lib/tanstack-query/hooks/*`) and the query-key convention seen in `src/features/predictions/queries.ts`. Add the new event type to `src/lib/events/emitter.ts` + handle it in `src/hooks/use-server-events.ts` (invalidate side-bets keys, mirroring how `match`/`reaction` are handled).
+- **Leaderboard UI:** mirror `src/features/predictions/components/prediction-leaderboard.tsx` and its route `src/app/routes/_authed/tournaments/$id.predictions.tsx` for a `$id.side-bets.tsx` route.
+- **Toasts:** `import { toast } from "@/lib/sonner"` โ `toast.success` / `toast.error` only.
+- **New feature dir:** `src/features/side-bets/{types.ts, server.ts, queries.ts, utils.ts, components/}` โ keep pure logic (pool split, house floor, OT payout) in `utils.ts` with unit-testable functions, like `src/features/predictions/utils.ts`.
+- **Design system:** Mantine 8 + the app theme; press feedback via the `flxn-press` theme; tasteful motion only (iOS-like easing, 120โ350ms) โ match neighboring components, don't invent a new look.
+
+## Tunables to revisit after first live use
+- House floor multiplier (default **1.5ร**).
+- OT multipliers (**2ร / 4ร / 8ร**).
+- Comeback token amount (**1**).
+- Whether an all-agreed pool refunds or voids.
diff --git a/package.json b/package.json
index 23b40b9..681d1b2 100644
--- a/package.json
+++ b/package.json
@@ -43,12 +43,14 @@
"supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0",
"vaul": "^1.1.2",
+ "web-push": "^3.6.7",
"zod": "^4.0.15"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+ "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
diff --git a/pb_migrations/1784100000_updated_matches.js b/pb_migrations/1784100000_updated_matches.js
new file mode 100644
index 0000000..526c46e
--- /dev/null
+++ b/pb_migrations/1784100000_updated_matches.js
@@ -0,0 +1,97 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2541054544")
+
+ // add field
+ collection.fields.addAt(24, new Field({
+ "hidden": false,
+ "id": "number9101010101",
+ "max": null,
+ "min": null,
+ "name": "reported_home_cups",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ }))
+
+ // add field
+ collection.fields.addAt(25, new Field({
+ "hidden": false,
+ "id": "number9202020202",
+ "max": null,
+ "min": null,
+ "name": "reported_away_cups",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ }))
+
+ // add field
+ collection.fields.addAt(26, new Field({
+ "hidden": false,
+ "id": "number9303030303",
+ "max": null,
+ "min": null,
+ "name": "reported_ot_count",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ }))
+
+ // add field
+ collection.fields.addAt(27, new Field({
+ "cascadeDelete": false,
+ "collectionId": "pbc_1568971955",
+ "hidden": false,
+ "id": "relation9404040404",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "reported_by_team",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ }))
+
+ // add field
+ collection.fields.addAt(28, new Field({
+ "cascadeDelete": false,
+ "collectionId": "pbc_3072146508",
+ "hidden": false,
+ "id": "relation9505050505",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "reported_by_player",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ }))
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2541054544")
+
+ // remove field
+ collection.fields.removeById("number9101010101")
+
+ // remove field
+ collection.fields.removeById("number9202020202")
+
+ // remove field
+ collection.fields.removeById("number9303030303")
+
+ // remove field
+ collection.fields.removeById("relation9404040404")
+
+ // remove field
+ collection.fields.removeById("relation9505050505")
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1784200000_created_push_subscriptions.js b/pb_migrations/1784200000_created_push_subscriptions.js
new file mode 100644
index 0000000..e204c00
--- /dev/null
+++ b/pb_migrations/1784200000_created_push_subscriptions.js
@@ -0,0 +1,128 @@
+///
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "pbc_3072146508",
+ "hidden": false,
+ "id": "relation_player_push",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "player",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_endpoint_push",
+ "max": 0,
+ "min": 0,
+ "name": "endpoint",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_p256dh_push",
+ "max": 0,
+ "min": 0,
+ "name": "p256dh",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_auth_push",
+ "max": 0,
+ "min": 0,
+ "name": "auth",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_user_agent_push",
+ "max": 0,
+ "min": 0,
+ "name": "user_agent",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_push_subscriptions",
+ "indexes": [
+ "CREATE UNIQUE INDEX `idx_push_subscriptions_endpoint` ON `push_subscriptions` (`endpoint`)"
+ ],
+ "listRule": null,
+ "name": "push_subscriptions",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_push_subscriptions");
+
+ return app.delete(collection);
+})
diff --git a/public/push-handlers.js b/public/push-handlers.js
new file mode 100644
index 0000000..f324800
--- /dev/null
+++ b/public/push-handlers.js
@@ -0,0 +1,58 @@
+/* eslint-disable no-undef */
+// Hand-written Web Push handlers, imported into the Workbox-generated SW via
+// `importScripts` (see scripts/generate-sw.mjs). Workbox never overwrites this
+// file โ it's copied verbatim from /public and pulled in at SW startup.
+
+self.addEventListener('push', (event) => {
+ let data = {};
+ try {
+ data = event.data ? event.data.json() : {};
+ } catch (e) {
+ data = { title: 'Notification', body: event.data ? event.data.text() : '' };
+ }
+
+ const title = data.title || 'Flexxon';
+ const options = {
+ body: data.body || '',
+ icon: data.icon || '/icon-192x192.png',
+ badge: '/icon-192x192.png',
+ tag: data.tag || undefined,
+ data: { url: data.url || '/' },
+ };
+
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+
+ const targetUrl = (event.notification.data && event.notification.data.url) || '/';
+
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: 'window', includeUncontrolled: true })
+ .then((clientList) => {
+ // Focus an existing tab if one is already open, else open a new one.
+ for (const client of clientList) {
+ try {
+ const clientUrl = new URL(client.url);
+ const target = new URL(targetUrl, self.location.origin);
+ if (clientUrl.pathname === target.pathname && 'focus' in client) {
+ return client.focus();
+ }
+ } catch (e) {
+ // ignore malformed URLs
+ }
+ }
+ for (const client of clientList) {
+ if ('focus' in client) {
+ client.navigate(targetUrl);
+ return client.focus();
+ }
+ }
+ if (self.clients.openWindow) {
+ return self.clients.openWindow(targetUrl);
+ }
+ })
+ );
+});
diff --git a/scripts/generate-sw.mjs b/scripts/generate-sw.mjs
index 05a4f21..a0dad01 100644
--- a/scripts/generate-sw.mjs
+++ b/scripts/generate-sw.mjs
@@ -14,7 +14,13 @@ const { count, size, warnings } = await generateSW({
'icon-512x512.png',
'site.webmanifest',
'styles.css',
+ 'push-handlers.js',
],
+ // Hand-written Web Push handlers (public/push-handlers.js) are pulled into
+ // the generated SW at startup. importScripts runs them in the SW global
+ // scope, so their `push` / `notificationclick` listeners compose with
+ // Workbox's precache + runtime caching setup below.
+ importScripts: ['/push-handlers.js'],
navigateFallback: null,
skipWaiting: true,
clientsClaim: true,
diff --git a/scripts/make-admin.ts b/scripts/make-admin.ts
new file mode 100644
index 0000000..6bf37eb
--- /dev/null
+++ b/scripts/make-admin.ts
@@ -0,0 +1,81 @@
+// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
+// Usage: bun run scripts/make-admin.ts [--revoke]
+import "dotenv/config";
+import SuperTokens from "supertokens-node";
+import Session from "supertokens-node/recipe/session";
+import Passwordless from "supertokens-node/recipe/passwordless";
+import UserRoles from "supertokens-node/recipe/userroles";
+
+SuperTokens.init({
+ framework: "custom",
+ supertokens: {
+ connectionURI: process.env.SUPERTOKENS_URI || "http://localhost:3567",
+ apiKey: process.env.SUPERTOKENS_API_KEY || undefined,
+ },
+ appInfo: {
+ appName: "FLXN",
+ apiDomain: "http://localhost:3000",
+ websiteDomain: "http://localhost:3000",
+ apiBasePath: "/api/auth",
+ websiteBasePath: "/auth",
+ },
+ recipeList: [
+ Passwordless.init({ contactMethod: "PHONE", flowType: "USER_INPUT_CODE" }),
+ Session.init(),
+ UserRoles.init(),
+ ],
+});
+
+const raw = process.argv[2];
+const revoke = process.argv.includes("--revoke");
+if (!raw) {
+ console.error("Usage: bun run scripts/make-admin.ts [--revoke]");
+ process.exit(1);
+}
+
+const digits = raw.replace(/[^\d]/g, "");
+const candidates = Array.from(
+ new Set([
+ raw.startsWith("+") ? raw : null,
+ digits.length === 10 ? `+1${digits}` : null,
+ `+${digits}`,
+ digits,
+ ].filter(Boolean) as string[])
+);
+
+let user: { id: string } | undefined;
+let matched = "";
+for (const phoneNumber of candidates) {
+ const users = await SuperTokens.listUsersByAccountInfo("public", { phoneNumber });
+ if (users.length) {
+ user = users[0];
+ matched = phoneNumber;
+ break;
+ }
+}
+
+if (!user) {
+ console.error(`No SuperTokens user found for phone (tried: ${candidates.join(", ")}).`);
+ console.error("The user must have logged in at least once so their account exists.");
+ process.exit(1);
+}
+
+await UserRoles.createNewRoleOrAddPermissions("Admin", []);
+
+if (revoke) {
+ const res = await UserRoles.removeUserRole("public", user.id, "Admin");
+ console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
+} else {
+ const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
+ console.log(
+ `Granted Admin to ${matched} (user ${user.id}):`,
+ res.status === "OK"
+ ? res.didUserAlreadyHaveRole
+ ? "already had it"
+ : "added"
+ : res.status
+ );
+}
+
+console.log("Note: sign out and back in (or refresh the session) for the role to take effect.");
+process.exit(0);
diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts
index 95862f1..cdc5fb1 100644
--- a/src/app/routeTree.gen.ts
+++ b/src/app/routeTree.gen.ts
@@ -29,6 +29,9 @@ import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resum
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
+import { Route as ApiPushUnsubscribeRouteImport } from './routes/api/push/unsubscribe'
+import { Route as ApiPushTestRouteImport } from './routes/api/push/test'
+import { Route as ApiPushSubscribeRouteImport } from './routes/api/push/subscribe'
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
@@ -149,6 +152,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
path: '/api/spotify/callback',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiPushUnsubscribeRoute = ApiPushUnsubscribeRouteImport.update({
+ id: '/api/push/unsubscribe',
+ path: '/api/push/unsubscribe',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiPushTestRoute = ApiPushTestRouteImport.update({
+ id: '/api/push/test',
+ path: '/api/push/test',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiPushSubscribeRoute = ApiPushSubscribeRouteImport.update({
+ id: '/api/push/subscribe',
+ path: '/api/push/subscribe',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
@@ -275,6 +293,9 @@ export interface FileRoutesByFullPath {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
+ '/api/push/subscribe': typeof ApiPushSubscribeRoute
+ '/api/push/test': typeof ApiPushTestRoute
+ '/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -314,6 +335,9 @@ export interface FileRoutesByTo {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
+ '/api/push/subscribe': typeof ApiPushSubscribeRoute
+ '/api/push/test': typeof ApiPushTestRoute
+ '/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -356,6 +380,9 @@ export interface FileRoutesById {
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
+ '/api/push/subscribe': typeof ApiPushSubscribeRoute
+ '/api/push/test': typeof ApiPushTestRoute
+ '/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -398,6 +425,9 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
+ | '/api/push/subscribe'
+ | '/api/push/test'
+ | '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -437,6 +467,9 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
+ | '/api/push/subscribe'
+ | '/api/push/test'
+ | '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -478,6 +511,9 @@ export interface FileRouteTypes {
| '/_authed/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
+ | '/api/push/subscribe'
+ | '/api/push/test'
+ | '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -509,6 +545,9 @@ export interface RootRouteChildren {
ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
+ ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
+ ApiPushTestRoute: typeof ApiPushTestRoute
+ ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
@@ -662,6 +701,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/push/unsubscribe': {
+ id: '/api/push/unsubscribe'
+ path: '/api/push/unsubscribe'
+ fullPath: '/api/push/unsubscribe'
+ preLoaderRoute: typeof ApiPushUnsubscribeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/push/test': {
+ id: '/api/push/test'
+ path: '/api/push/test'
+ fullPath: '/api/push/test'
+ preLoaderRoute: typeof ApiPushTestRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/push/subscribe': {
+ id: '/api/push/subscribe'
+ path: '/api/push/subscribe'
+ fullPath: '/api/push/subscribe'
+ preLoaderRoute: typeof ApiPushSubscribeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
@@ -874,6 +934,9 @@ const rootRouteChildren: RootRouteChildren = {
ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute,
+ ApiPushSubscribeRoute: ApiPushSubscribeRoute,
+ ApiPushTestRoute: ApiPushTestRoute,
+ ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx
index 30fb4e6..5d1f121 100644
--- a/src/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -44,10 +44,10 @@ export const Route = createRootRouteWithContext<{
content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
},
- { name: 'description', content: 'Amicus meus madidus' },
+ { name: 'description', content: 'FLXN โ beer pong tournaments, brackets, and stats.' },
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
{ property: 'og:title', content: 'FLXN' },
- { property: 'og:description', content: 'Amicus meus madidus' },
+ { property: 'og:description', content: 'FLXN โ beer pong tournaments, brackets, and stats.' },
{ property: 'og:url', content: 'https://flexxon.app' },
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'FLXN' },
@@ -58,7 +58,7 @@ export const Route = createRootRouteWithContext<{
{ property: 'og:locale', content: 'en_US' },
{ name: 'twitter:card', content: 'summary' },
{ name: 'twitter:title', content: 'FLXN' },
- { name: 'twitter:description', content: 'Amicus meus madidus' },
+ { name: 'twitter:description', content: 'FLXN โ beer pong tournaments, brackets, and stats.' },
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
index a4ecd30..6b8200a 100644
--- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx
+++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
@@ -97,6 +97,16 @@ function RouteComponent() {
) || false;
}, [tournament.matches]);
+ const nextUpMatchId = useMemo(() => {
+ const ready = (tournament.matches ?? [])
+ .filter(
+ (match) =>
+ match.status === "ready" && match.home && match.away && !match.bye
+ )
+ .sort((a, b) => a.order - b.order);
+ return ready[0]?.id;
+ }, [tournament.matches]);
+
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
@@ -146,11 +156,12 @@ function RouteComponent() {
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
+ nextUpMatchId={nextUpMatchId}
/>
Knockout Bracket
-
+
) : hasGroupStage ? (
@@ -162,9 +173,10 @@ function RouteComponent() {
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
+ nextUpMatchId={nextUpMatchId}
/>
) : (
-
+
)
) : (
tournament.regional === true ? (
diff --git a/src/app/routes/_authed/settings.tsx b/src/app/routes/_authed/settings.tsx
index bc5f152..1725d2b 100644
--- a/src/app/routes/_authed/settings.tsx
+++ b/src/app/routes/_authed/settings.tsx
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { Box, Title, Stack } from "@mantine/core";
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
+import { NotificationsSection } from "@/features/settings/components/notifications-section";
import { SignOutIcon } from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
@@ -32,6 +33,7 @@ function RouteComponent() {
+
>
);
diff --git a/src/app/routes/api/push/subscribe.ts b/src/app/routes/api/push/subscribe.ts
new file mode 100644
index 0000000..51b77b7
--- /dev/null
+++ b/src/app/routes/api/push/subscribe.ts
@@ -0,0 +1,52 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { pbAdmin } from "@/lib/pocketbase/client";
+import { logger } from "@/lib/logger";
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+
+export const Route = createFileRoute("/api/push/subscribe")({
+ server: {
+ middleware: [superTokensRequestMiddleware],
+ handlers: {
+ POST: async ({ request, context }) => {
+ const player = (context as any).player;
+ if (!player) return json({ error: "No player for session" }, 401);
+
+ let payload: any;
+ try {
+ payload = await request.json();
+ } catch {
+ return json({ error: "Invalid JSON" }, 400);
+ }
+
+ const sub = payload?.subscription ?? payload;
+ const endpoint: string | undefined = sub?.endpoint;
+ const p256dh: string | undefined = sub?.keys?.p256dh;
+ const auth: string | undefined = sub?.keys?.auth;
+
+ if (!endpoint || !p256dh || !auth) {
+ return json({ error: "Invalid subscription" }, 400);
+ }
+
+ try {
+ await pbAdmin.upsertPushSubscription({
+ player: player.id,
+ endpoint,
+ p256dh,
+ auth,
+ user_agent: request.headers.get("user-agent") ?? undefined,
+ });
+ return json({ ok: true });
+ } catch (error) {
+ logger.error("Push | subscribe failed", error);
+ return json({ error: "Failed to store subscription" }, 500);
+ }
+ },
+ },
+ },
+});
diff --git a/src/app/routes/api/push/test.ts b/src/app/routes/api/push/test.ts
new file mode 100644
index 0000000..718dedf
--- /dev/null
+++ b/src/app/routes/api/push/test.ts
@@ -0,0 +1,40 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { sendPushToPlayer } from "@/lib/push";
+import { isPushConfigured } from "@/lib/config";
+import { logger } from "@/lib/logger";
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+
+export const Route = createFileRoute("/api/push/test")({
+ server: {
+ middleware: [superTokensRequestMiddleware],
+ handlers: {
+ POST: async ({ context }) => {
+ const player = (context as any).player;
+ if (!player) return json({ error: "No player for session" }, 401);
+
+ if (!isPushConfigured()) {
+ return json({ error: "Push is not configured on the server" }, 503);
+ }
+
+ try {
+ const result = await sendPushToPlayer(player.id, {
+ title: "Flexxon",
+ body: "Test notification โ push is working on this device.",
+ url: "/settings",
+ tag: "flexxon-test",
+ });
+ return json({ ok: true, ...result });
+ } catch (error) {
+ logger.error("Push | test send failed", error);
+ return json({ error: "Failed to send test notification" }, 500);
+ }
+ },
+ },
+ },
+});
diff --git a/src/app/routes/api/push/unsubscribe.ts b/src/app/routes/api/push/unsubscribe.ts
new file mode 100644
index 0000000..8e3c687
--- /dev/null
+++ b/src/app/routes/api/push/unsubscribe.ts
@@ -0,0 +1,41 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { pbAdmin } from "@/lib/pocketbase/client";
+import { logger } from "@/lib/logger";
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+
+export const Route = createFileRoute("/api/push/unsubscribe")({
+ server: {
+ middleware: [superTokensRequestMiddleware],
+ handlers: {
+ POST: async ({ request, context }) => {
+ const player = (context as any).player;
+ if (!player) return json({ error: "No player for session" }, 401);
+
+ let payload: any;
+ try {
+ payload = await request.json();
+ } catch {
+ return json({ error: "Invalid JSON" }, 400);
+ }
+
+ const endpoint: string | undefined =
+ payload?.endpoint ?? payload?.subscription?.endpoint;
+ if (!endpoint) return json({ error: "Missing endpoint" }, 400);
+
+ try {
+ await pbAdmin.deletePushSubscriptionByEndpoint(endpoint);
+ return json({ ok: true });
+ } catch (error) {
+ logger.error("Push | unsubscribe failed", error);
+ return json({ error: "Failed to remove subscription" }, 500);
+ }
+ },
+ },
+ },
+});
diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx
index 52565d5..f6ed3c5 100644
--- a/src/features/bracket/components/bracket-view.tsx
+++ b/src/features/bracket/components/bracket-view.tsx
@@ -16,9 +16,10 @@ interface BracketViewProps {
};
renderMatch?: (match: Match) => React.ReactNode;
bottomOffset?: number;
+ nextUpMatchId?: string;
}
-const BracketView: React.FC = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => {
+const BracketView: React.FC = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset, nextUpMatchId }) => {
const height = useAppShellHeight();
const viewportRef = useRef(null);
const hasAutoScrolled = useRef(false);
@@ -50,9 +51,10 @@ const BracketView: React.FC = ({ bracket, showControls, groupC
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
(match) => !match.bye
);
- const target =
- matches.find((match) => match.status === "started") ??
- matches.find((match) => match.status === "ready");
+ const target = nextUpMatchId !== undefined
+ ? matches.find((match) => match.id === nextUpMatchId)
+ : matches.find((match) => match.status === "started") ??
+ matches.find((match) => match.status === "ready");
if (!target) return;
const viewport = viewportRef.current;
@@ -78,7 +80,7 @@ const BracketView: React.FC = ({ bracket, showControls, groupC
? "auto"
: "smooth",
});
- }, [bracket]);
+ }, [bracket, nextUpMatchId]);
return
= ({ bracket, showControls, groupC
Winners Bracket
-
+
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
Losers Bracket
-
+
)}
{bottomOffset ? : null}
diff --git a/src/features/bracket/components/bracket.tsx b/src/features/bracket/components/bracket.tsx
index b1e6863..d8b6bd9 100644
--- a/src/features/bracket/components/bracket.tsx
+++ b/src/features/bracket/components/bracket.tsx
@@ -14,6 +14,7 @@ interface BracketProps {
renderMatch?: (match: Match) => React.ReactNode;
onMatchTap?: (match: Match) => void;
selectedMatchLid?: number | null;
+ nextUpMatchId?: string;
}
export const Bracket: React.FC = ({
@@ -24,6 +25,7 @@ export const Bracket: React.FC = ({
renderMatch,
onMatchTap,
selectedMatchLid,
+ nextUpMatchId,
}) => {
const containerRef = useRef(null);
const svgRef = useRef(null);
@@ -149,6 +151,7 @@ export const Bracket: React.FC = ({
groupConfig={groupConfig}
onTap={onMatchTap}
selected={selectedMatchLid === match.lid}
+ nextUpMatchId={nextUpMatchId}
/>
)}
diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx
index f833327..0e2ff88 100644
--- a/src/features/bracket/components/match-card.tsx
+++ b/src/features/bracket/components/match-card.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
+import { ActionIcon, Card, Flex, Text, Indicator, Stack } from "@mantine/core";
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
import React, { useCallback, useMemo } from "react";
import { MatchSlot } from "./match-slot";
@@ -6,6 +6,8 @@ import { Match } from "@/features/matches/types";
import { Team } from "@/features/teams/types";
import { useSheet } from "@/hooks/use-sheet";
import { MatchForm } from "./match-form";
+import { MatchReport } from "./match-report";
+import toast from "@/lib/sonner";
import Sheet from "@/components/sheet/sheet";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { endMatch, startMatch } from "@/features/matches/server";
@@ -25,6 +27,7 @@ interface MatchCardProps {
};
onTap?: (match: Match) => void;
selected?: boolean;
+ nextUpMatchId?: string;
}
export const MatchCard: React.FC = ({
@@ -34,6 +37,7 @@ export const MatchCard: React.FC = ({
groupConfig,
onTap,
selected,
+ nextUpMatchId,
}) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
@@ -41,6 +45,8 @@ export const MatchCard: React.FC = ({
const canTap = !!(onTap && match.home && match.away);
+ const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
+
const homeSlot = useMemo(
() => ({
from: orders[match.home_from_lid],
@@ -80,7 +86,8 @@ export const MatchCard: React.FC = ({
);
const showEditButton = useMemo(
- () => showControls && match.status === "started",
+ () =>
+ showControls && (match.status === "started" || match.status === "ended"),
[showControls, match.status]
);
@@ -98,11 +105,21 @@ export const MatchCard: React.FC = ({
const end = useServerMutation({
mutationFn: endMatch,
- onSuccess: () => {
+ onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id),
});
editSheet.close();
+ if (data?.downstreamReset) {
+ toast.error(
+ "Downstream matches were reset because the correction changed who advances."
+ );
+ }
+ if (data?.groupEditAfterKnockout) {
+ toast.error(
+ "Group result changed after the bracket was seeded โ reseed the knockout stage if needed."
+ );
+ }
},
});
@@ -195,10 +212,8 @@ export const MatchCard: React.FC = ({
data: match.id,
});
- // Skip announcements for regional tournaments
const isRegional = match.tournament?.regional === true;
- // Play walkout sequence after starting the match (only for non-regional tournaments)
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
try {
const homeTeam = match.home as Team;
@@ -217,152 +232,168 @@ export const MatchCard: React.FC = ({
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
return (
-
-
- {match.order}
-
-
-
+
+
- onTap!(match) : undefined}
- role={canTap ? "button" : undefined}
- tabIndex={canTap ? 0 : undefined}
- onKeyDown={
- canTap
- ? (e: React.KeyboardEvent) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onTap!(match);
- }
- }
- : undefined
- }
- aria-label={
- canTap
- ? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
- : undefined
- }
- style={{
- overflow: "visible",
- backgroundColor: 'var(--mantine-color-body)',
- borderColor: selected
- ? 'var(--mantine-primary-color-filled)'
- : 'var(--mantine-color-default-border)',
- boxShadow: 'var(--mantine-shadow-sm)',
- }}
- data-match-lid={match.lid}
+ {match.order}
+
+
+
-
-
-
+ onTap!(match) : undefined}
+ role={canTap ? "button" : undefined}
+ tabIndex={canTap ? 0 : undefined}
+ onKeyDown={
+ canTap
+ ? (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onTap!(match);
+ }
+ }
+ : undefined
+ }
+ aria-label={
+ canTap
+ ? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
+ : undefined
+ }
+ style={{
+ overflow: "visible",
+ backgroundColor: 'var(--mantine-color-body)',
+ borderColor: selected
+ ? 'var(--mantine-primary-color-filled)'
+ : 'var(--mantine-color-default-border)',
+ boxShadow: 'var(--mantine-shadow-sm)',
+ transition:
+ 'border-color 200ms cubic-bezier(0.32, 0.72, 0, 1)',
+ }}
+ data-match-lid={match.lid}
+ >
+
+
+
-
-
-
+
+
+
- {match.reset && (
-
- * If necessary
-
- )}
+ {match.reset && (
+
+ * If necessary
+
+ )}
- {showControls && match.status !== "tbd" && (
+ {showControls && match.status !== "tbd" && (
+ {
+ e.stopPropagation();
+ handleSpeakerClick();
+ }}
+ aria-label="Announce matchup"
+ >
+
+
+ )}
+
+
+
+ {showToolbar && (
+
{
- e.stopPropagation();
- handleSpeakerClick();
+ h="100%"
+ radius="sm"
+ ml={-4}
+ aria-label="Start match"
+ style={{
+ borderTopLeftRadius: 0,
+ borderBottomLeftRadius: 0,
}}
- aria-label="Announce matchup"
>
-
+
- )}
-
-
-
- {showToolbar && (
-
-
-
-
-
- )}
+
+ )}
- {showEditButton && (
-
-
-
-
-
- )}
+ {showEditButton && (
+
+
+
+
+
+ )}
+
+
+
+
+
-
-
-
-
+ {!showControls && }
+
);
};
diff --git a/src/features/bracket/components/match-dock.tsx b/src/features/bracket/components/match-dock.tsx
index dc0ba70..1fbe899 100644
--- a/src/features/bracket/components/match-dock.tsx
+++ b/src/features/bracket/components/match-dock.tsx
@@ -20,6 +20,7 @@ import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
+import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
@@ -149,6 +150,8 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended}
/>
+
+
+ !t ? undefined : typeof t === "string" ? t : t.id;
+
+const teamPlayerIds = (t: Match["home"]): string[] =>
+ t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
+
+const teamName = (t: Match["home"]): string | undefined =>
+ t && typeof t !== "string" ? t.name : undefined;
+
+interface MatchReportProps {
+ match: Match;
+ compact?: boolean;
+}
+
+export const MatchReport: React.FC = ({ match, compact }) => {
+ const { user } = useAuth();
+ const formSheet = useSheet();
+ const [acknowledgedReport, setAcknowledgedReport] = useState(
+ null
+ );
+
+ const homeId = teamId(match.home);
+ const onHome = !!user?.id && teamPlayerIds(match.home).includes(user.id);
+ const onAway = !!user?.id && teamPlayerIds(match.away).includes(user.id);
+ const isParticipant = onHome || onAway;
+
+ const hasPending =
+ match.reported_by_team != null &&
+ match.reported_home_cups != null &&
+ match.reported_away_cups != null;
+
+ const reportedByHome = hasPending && match.reported_by_team === homeId;
+ const reportingTeam = hasPending
+ ? reportedByHome
+ ? match.home
+ : match.away
+ : undefined;
+ const opposingTeam = reportedByHome ? match.away : match.home;
+ const reportedByMine =
+ hasPending && ((reportedByHome && onHome) || (!reportedByHome && onAway));
+
+ const pendingSignature = hasPending
+ ? [
+ match.reported_by_team,
+ match.reported_home_cups,
+ match.reported_away_cups,
+ match.reported_ot_count ?? 0,
+ ].join(":")
+ : null;
+
+ const sheetTakenOver =
+ formSheet.isOpen &&
+ isParticipant &&
+ hasPending &&
+ !reportedByMine &&
+ pendingSignature !== acknowledgedReport;
+
+ const report = useReportMatchScore(user?.id, {
+ onSuccess: (data: any) => {
+ if (data?.finalized) {
+ toast.success("Score confirmed ๐ป");
+ if (data.downstreamReset) {
+ toast.error(
+ "Downstream matches were reset because the correction changed who advances."
+ );
+ }
+ if (data.groupEditAfterKnockout) {
+ toast.error(
+ "Group result changed after the bracket was seeded โ reseed the knockout stage if needed."
+ );
+ }
+ }
+ },
+ });
+
+ const confirm = useConfirmMatchScore({
+ onSuccess: (data: any) => {
+ if (data?.downstreamReset) {
+ toast.error(
+ "Downstream matches were reset because the correction changed who advances."
+ );
+ }
+ if (data?.groupEditAfterKnockout) {
+ toast.error(
+ "Group result changed after the bracket was seeded โ reseed the knockout stage if needed."
+ );
+ }
+ },
+ });
+
+ const clear = useClearMatchReport();
+
+ const submitReport = useCallback(
+ (data: { home_cups: number; away_cups: number; ot_count: number }) => {
+ report.mutate({ data: { ...data, matchId: match.id } });
+ formSheet.close();
+ toast.success("Score submitted โ waiting for the other team");
+ },
+ [report, match.id, formSheet.close]
+ );
+
+ const submitConfirm = useCallback(() => {
+ confirm.mutate({ data: { matchId: match.id } });
+ formSheet.close();
+ toast.success("Score confirmed ๐ป");
+ }, [confirm, match.id, formSheet.close]);
+
+ const submitClear = useCallback(() => {
+ clear.mutate({ data: { matchId: match.id } });
+ toast.success("Report cleared");
+ }, [clear, match.id]);
+
+ const openForm = useCallback(() => {
+ setAcknowledgedReport(pendingSignature);
+ formSheet.open();
+ }, [pendingSignature, formSheet.open]);
+
+ const dismissTakeover = useCallback(() => {
+ setAcknowledgedReport(pendingSignature);
+ }, [pendingSignature]);
+
+ if (match.status !== "started") return null;
+
+ const scoreText = hasPending
+ ? `${match.reported_home_cups}โ${match.reported_away_cups}`
+ : "";
+ const size = compact ? "compact-xs" : "sm";
+
+ const formSheetEl = (
+
+ {sheetTakenOver ? (
+
+ }
+ title={`${teamName(reportingTeam) ?? "The other team"} just reported a score`}
+ >
+ Does this look right?
+
+
+
+
+
+
+ {teamName(match.home) ?? "Home"}
+
+
+ {match.reported_home_cups}
+
+
+
+ โ
+
+
+
+ {teamName(match.away) ?? "Away"}
+
+
+ {match.reported_away_cups}
+
+
+
+
+
+
+ }
+ onClick={submitConfirm}
+ loading={confirm.isPending}
+ >
+ Confirm {scoreText}
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+
+ if (isParticipant && !hasPending) {
+ return (
+ <>
+
+ {formSheetEl}
+ >
+ );
+ }
+
+ if (hasPending) {
+ if (reportedByMine) {
+ return (
+
+
+ Waiting for {teamName(opposingTeam) ?? "the other team"} to confirm{" "}
+ ยท{" "}
+
+ {scoreText}
+
+
+
+
+ );
+ }
+
+ if (isParticipant) {
+ return (
+ <>
+
+
+ {teamName(reportingTeam) ?? "Opponent"} reported{" "}
+
+ {scoreText}
+
+
+
+ }
+ onClick={submitConfirm}
+ loading={confirm.isPending}
+ style={{ flex: 1 }}
+ >
+ Confirm
+
+
+
+
+ {formSheetEl}
+ >
+ );
+ }
+
+ return (
+
+ Pending: {scoreText} ยท reported by {teamName(reportingTeam) ?? "a team"}
+
+ );
+ }
+
+ return null;
+};
diff --git a/src/features/login/components/layout.tsx b/src/features/login/components/layout.tsx
index d1228c1..fc42455 100644
--- a/src/features/login/components/layout.tsx
+++ b/src/features/login/components/layout.tsx
@@ -1,7 +1,7 @@
import GlitchAvatar from '@/components/glitch-avatar';
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
import { useCurrentTournament } from '@/features/tournaments/queries';
-import { AppShell, Flex, Paper, em, Title, Text, Stack } from '@mantine/core';
+import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
@@ -27,7 +27,6 @@ const Layout: React.FC = ({ children }) => {
style={{ transition: 'padding-top 0.1s ease' }}
>
= ({ children }) => {
>
-
- Welcome to FLXN
-
- Amicus meus madidus
-
-
+ Welcome to FLXN
{children}
diff --git a/src/features/matches/queries.ts b/src/features/matches/queries.ts
index 81f5e0c..5990d5b 100644
--- a/src/features/matches/queries.ts
+++ b/src/features/matches/queries.ts
@@ -1,5 +1,13 @@
-import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
-import { getMatchesBetweenTeams, getMatchesBetweenPlayers } from "./server";
+import { useQueryClient } from "@tanstack/react-query";
+import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
+import { Match } from "@/features/matches/types";
+import {
+ getMatchesBetweenTeams,
+ getMatchesBetweenPlayers,
+ reportMatchScore,
+ confirmMatchScore,
+ clearMatchReport,
+} from "./server";
export const matchKeys = {
headToHeadTeams: (team1Id: string, team2Id: string) => ['matches', 'headToHead', 'teams', team1Id, team2Id] as const,
@@ -28,3 +36,130 @@ export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enable
...matchQueries.headToHeadPlayers(player1Id, player2Id),
enabled,
});
+
+const tournamentsRoot = { queryKey: ["tournaments"] as const };
+
+const reportTeamId = (t: Match["home"]): string | undefined =>
+ !t ? undefined : typeof t === "string" ? t : t.id;
+
+const reportTeamPlayerIds = (t: Match["home"]): string[] =>
+ t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
+
+function patchTournamentsData(
+ data: unknown,
+ matchId: string,
+ patch: (match: Match) => Partial
+): unknown {
+ if (!data) return data;
+ if (Array.isArray(data)) {
+ return data.map((entry) => patchTournamentsData(entry, matchId, patch));
+ }
+ const tournament = data as { matches?: Match[] };
+ if (!Array.isArray(tournament.matches)) return data;
+
+ let changed = false;
+ const matches = tournament.matches.map((m) => {
+ if (m.id !== matchId) return m;
+ changed = true;
+ return { ...m, ...patch(m) };
+ });
+ return changed ? { ...tournament, matches } : data;
+}
+
+type MatchVariables = { data: { matchId: string } };
+type TournamentsSnapshot = [readonly unknown[], unknown][];
+
+function useOptimisticMatchMutation(
+ options: Parameters>[0] & {
+ buildPatch: (match: Match, variables: TVariables) => Partial;
+ }
+) {
+ const queryClient = useQueryClient();
+ const { buildPatch, ...mutationOptions } = options;
+
+ return useServerMutation({
+ ...mutationOptions,
+ onMutate: async (variables) => {
+ const { matchId } = variables.data;
+ await queryClient.cancelQueries(tournamentsRoot);
+
+ const previous = queryClient.getQueriesData(tournamentsRoot);
+ queryClient.setQueriesData(tournamentsRoot, (data: unknown) =>
+ patchTournamentsData(data, matchId, (match) => buildPatch(match, variables))
+ );
+
+ return { previous };
+ },
+ onError: (error, variables, onMutateResult, context) => {
+ if (context && typeof context === "object" && "previous" in context) {
+ const snapshot = (context as { previous: TournamentsSnapshot }).previous;
+ for (const [key, data] of snapshot) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ mutationOptions.onError?.(error, variables, onMutateResult, context);
+ },
+ onSettled: (data, error, variables, onMutateResult, context) => {
+ queryClient.invalidateQueries(tournamentsRoot);
+ mutationOptions.onSettled?.(data, error, variables, onMutateResult, context);
+ },
+ });
+}
+
+type MatchMutationOptions = {
+ onSuccess?: Parameters[0]["onSuccess"];
+};
+
+export const useReportMatchScore = (userId?: string, options?: MatchMutationOptions) =>
+ useOptimisticMatchMutation({
+ mutationFn: reportMatchScore,
+ showSuccessToast: false,
+ ...options,
+ buildPatch: (match, variables) => {
+ const onHome = !!userId && reportTeamPlayerIds(match.home).includes(userId);
+ const onAway = !!userId && reportTeamPlayerIds(match.away).includes(userId);
+ const callerTeamId = onHome
+ ? reportTeamId(match.home)
+ : onAway
+ ? reportTeamId(match.away)
+ : undefined;
+ return {
+ reported_home_cups: variables.data.home_cups,
+ reported_away_cups: variables.data.away_cups,
+ reported_ot_count: variables.data.ot_count,
+ reported_by_team: callerTeamId,
+ reported_by_player: userId,
+ };
+ },
+ });
+
+export const useConfirmMatchScore = (options?: MatchMutationOptions) =>
+ useOptimisticMatchMutation({
+ mutationFn: confirmMatchScore,
+ showSuccessToast: false,
+ ...options,
+ buildPatch: (match) => ({
+ status: "ended",
+ home_cups: match.reported_home_cups ?? match.home_cups,
+ away_cups: match.reported_away_cups ?? match.away_cups,
+ ot_count: match.reported_ot_count ?? match.ot_count,
+ reported_home_cups: undefined,
+ reported_away_cups: undefined,
+ reported_ot_count: undefined,
+ reported_by_team: undefined,
+ reported_by_player: undefined,
+ }),
+ });
+
+export const useClearMatchReport = (options?: MatchMutationOptions) =>
+ useOptimisticMatchMutation({
+ mutationFn: clearMatchReport,
+ ...options,
+ buildPatch: () => ({
+ reported_home_cups: undefined,
+ reported_away_cups: undefined,
+ reported_ot_count: undefined,
+ reported_by_team: undefined,
+ reported_by_player: undefined,
+ }),
+ });
diff --git a/src/features/matches/server.ts b/src/features/matches/server.ts
index 3052219..bfde9d3 100644
--- a/src/features/matches/server.ts
+++ b/src/features/matches/server.ts
@@ -212,7 +212,6 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
const standings = new Map();
for (const team of group.teams || []) {
- // group.teams can be either team objects or just team ID strings
const teamId = typeof team === 'string' ? team : team.id;
standings.set(teamId, {
teamId,
@@ -456,6 +455,344 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
logger.info('Knockout bracket populated successfully', { tournamentId });
}
+const teamId = (t: Match["home"] | string | undefined): string | undefined =>
+ !t ? undefined : typeof t === "string" ? t : t.id;
+
+const winnerId = (m: Match): string | undefined =>
+ m.home_cups > m.away_cups ? teamId(m.home) : teamId(m.away);
+const loserId = (m: Match): string | undefined =>
+ m.home_cups > m.away_cups ? teamId(m.away) : teamId(m.home);
+
+function assertValidScore(home_cups: number, away_cups: number, ot_count: number) {
+ if (home_cups === away_cups) throw new Error("A match cannot end in a tie");
+ if (ot_count > 0) return;
+ if (home_cups !== 10 && away_cups !== 10)
+ throw new Error("At least one team must have 10 cups");
+ if (home_cups === 10 && away_cups === 10)
+ throw new Error("Both teams cannot have 10 cups");
+}
+
+const CLEARED_RESULT = {
+ home_cups: 0,
+ away_cups: 0,
+ ot_count: 0,
+ start_time: "",
+ end_time: "",
+ reported_home_cups: null,
+ reported_away_cups: null,
+ reported_ot_count: null,
+ reported_by_team: null,
+ reported_by_player: null,
+} as const;
+
+function generateRecordId(): string {
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
+ let id = "";
+ for (let i = 0; i < 15; i++)
+ id += alphabet[Math.floor(Math.random() * alphabet.length)];
+ return id;
+}
+
+type BracketOp =
+ | { kind: "updateMatch"; id: string; data: Record }
+ | { kind: "createMatch"; data: Record }
+ | { kind: "deleteMatch"; id: string }
+ | { kind: "updateTournamentMatches"; tournamentId: string; matchIds: string[] };
+
+function isBatchApiUnavailable(error: unknown): boolean {
+ if (!error || typeof error !== "object") return false;
+ const e = error as { status?: unknown; url?: unknown };
+ const url = typeof e.url === "string" ? e.url : "";
+ if (!url.includes("/api/batch")) return false;
+ return e.status === 403 || e.status === 404;
+}
+
+async function commitBracketWrites(ops: BracketOp[]): Promise {
+ const batch = pbAdmin.createBatch();
+ for (const op of ops) {
+ switch (op.kind) {
+ case "updateMatch":
+ batch.collection("matches").update(op.id, op.data);
+ break;
+ case "createMatch":
+ batch.collection("matches").create(op.data);
+ break;
+ case "deleteMatch":
+ batch.collection("matches").delete(op.id);
+ break;
+ case "updateTournamentMatches":
+ batch
+ .collection("tournaments")
+ .update(op.tournamentId, { matches: op.matchIds });
+ break;
+ }
+ }
+
+ try {
+ await batch.send();
+ return;
+ } catch (error) {
+ if (!isBatchApiUnavailable(error)) throw error;
+ logger.warn(
+ "PocketBase batch API is disabled; finalizing match with sequential writes (reduced atomicity). Enable Settings > Application > batch API to restore transactional bracket updates.",
+ );
+ }
+
+ for (const op of ops) {
+ switch (op.kind) {
+ case "updateMatch":
+ await pbAdmin.updateMatch(op.id, op.data as Partial);
+ break;
+ case "createMatch":
+ await pbAdmin.createMatch(op.data as unknown as MatchInput);
+ break;
+ case "deleteMatch":
+ await pbAdmin.deleteMatch(op.id);
+ break;
+ case "updateTournamentMatches":
+ await pbAdmin.updateTournamentMatches(op.tournamentId, op.matchIds);
+ break;
+ }
+ }
+}
+
+async function rederiveBracket(
+ tournamentId: string,
+ sourceId: string,
+ score: { home_cups: number; away_cups: number; ot_count: number }
+): Promise<{ source: Match; downstreamReset: boolean }> {
+ const all = await pbAdmin.getMatchesByTournament(tournamentId);
+ const knockout = all.filter((m) => m.lid >= 0);
+ const byLid = new Map();
+ for (const m of knockout) byLid.set(m.lid, m);
+
+ const original = all.find((m) => m.id === sourceId);
+ if (!original) throw new Error("Match not found");
+
+ const ops: BracketOp[] = [];
+ let warn = false;
+
+ const nowIso = new Date().toISOString();
+ ops.push({
+ kind: "updateMatch",
+ id: sourceId,
+ data: {
+ end_time: nowIso,
+ status: "ended",
+ home_cups: score.home_cups,
+ away_cups: score.away_cups,
+ ot_count: score.ot_count,
+ reported_home_cups: null,
+ reported_away_cups: null,
+ reported_ot_count: null,
+ reported_by_team: null,
+ reported_by_player: null,
+ },
+ });
+ const source: Match = {
+ ...original,
+ status: "ended",
+ home_cups: score.home_cups,
+ away_cups: score.away_cups,
+ ot_count: score.ot_count,
+ end_time: nowIso,
+ reported_home_cups: undefined,
+ reported_away_cups: undefined,
+ reported_ot_count: undefined,
+ reported_by_team: undefined,
+ reported_by_player: undefined,
+ };
+ byLid.set(source.lid, source);
+
+ const resolveFeeder = (lid: number, fromLoser: boolean): string | undefined => {
+ const f = byLid.get(lid);
+ if (!f || f.bye || f.status !== "ended") return undefined;
+ return fromLoser ? loserId(f) : winnerId(f);
+ };
+
+ const hasLosers = knockout.some((m) => m.is_losers_bracket);
+ const resetMatch = knockout.find((m) => m.reset);
+ const grandFinal = hasLosers
+ ? knockout
+ .filter((m) => !m.reset && !m.bye)
+ .reduce(
+ (hi, cur) => (!hi || cur.lid > hi.lid ? cur : hi),
+ undefined
+ )
+ : undefined;
+
+ const ordered = knockout
+ .filter((m) => !m.bye && !m.reset)
+ .sort((a, b) => a.lid - b.lid);
+
+ for (const m of ordered) {
+ if (m.id === sourceId) continue;
+
+ const homeFed = m.home_from_lid >= 0;
+ const awayFed = m.away_from_lid >= 0;
+ if (!homeFed && !awayFed) continue;
+
+ const curHome = teamId(m.home);
+ const curAway = teamId(m.away);
+ const expHome = homeFed ? resolveFeeder(m.home_from_lid, m.home_from_loser) : curHome;
+ const expAway = awayFed ? resolveFeeder(m.away_from_lid, m.away_from_loser) : curAway;
+ const expStatus = expHome && expAway ? "ready" : "tbd";
+
+ const played = m.status === "started" || m.status === "ended";
+ const changed =
+ (homeFed && expHome !== curHome) || (awayFed && expAway !== curAway);
+
+ if (played && changed) {
+ ops.push({
+ kind: "updateMatch",
+ id: m.id,
+ data: {
+ home: expHome ?? null,
+ away: expAway ?? null,
+ status: expStatus,
+ ...CLEARED_RESULT,
+ },
+ });
+ byLid.set(m.lid, { ...m, status: expStatus });
+ warn = true;
+ } else if (!played && (changed || m.status !== expStatus)) {
+ ops.push({
+ kind: "updateMatch",
+ id: m.id,
+ data: {
+ home: expHome ?? null,
+ away: expAway ?? null,
+ status: expStatus,
+ },
+ });
+ byLid.set(m.lid, { ...m, status: expStatus });
+ }
+ }
+
+ if (grandFinal) {
+ const gf = byLid.get(grandFinal.lid)!;
+ const gfEnded = gf.status === "ended";
+ const resetNeeded = gfEnded && winnerId(gf) === teamId(gf.away);
+
+ if (resetNeeded) {
+ const expHome = winnerId(gf);
+ const expAway = loserId(gf);
+
+ if (resetMatch && resetMatch.id !== sourceId) {
+ const played =
+ resetMatch.status === "started" || resetMatch.status === "ended";
+ const changed =
+ teamId(resetMatch.home) !== expHome || teamId(resetMatch.away) !== expAway;
+ if (played && changed) {
+ ops.push({
+ kind: "updateMatch",
+ id: resetMatch.id,
+ data: {
+ home: expHome ?? null,
+ away: expAway ?? null,
+ status: "ready",
+ ...CLEARED_RESULT,
+ },
+ });
+ warn = true;
+ } else if (!played && (changed || resetMatch.status !== "ready")) {
+ ops.push({
+ kind: "updateMatch",
+ id: resetMatch.id,
+ data: {
+ home: expHome ?? null,
+ away: expAway ?? null,
+ status: "ready",
+ },
+ });
+ }
+ } else if (!resetMatch) {
+ const newId = generateRecordId();
+ ops.push({
+ kind: "createMatch",
+ data: {
+ id: newId,
+ lid: gf.lid + 1,
+ order: gf.order + 1,
+ round: gf.round + 1,
+ reset: true,
+ bye: false,
+ home_cups: 0,
+ away_cups: 0,
+ ot_count: 0,
+ home_from_lid: gf.lid,
+ away_from_lid: gf.lid,
+ home_from_loser: false,
+ away_from_loser: true,
+ is_losers_bracket: false,
+ status: "ready",
+ tournament: tournamentId,
+ home: expHome ?? undefined,
+ away: expAway ?? undefined,
+ },
+ });
+ ops.push({
+ kind: "updateTournamentMatches",
+ tournamentId,
+ matchIds: [...all.map((m) => m.id), newId],
+ });
+ }
+ } else if (resetMatch && resetMatch.id !== sourceId) {
+ if (resetMatch.status === "started" || resetMatch.status === "ended") {
+ warn = true;
+ }
+ ops.push({ kind: "deleteMatch", id: resetMatch.id });
+ }
+ }
+
+ await commitBracketWrites(ops);
+
+ return { source, downstreamReset: warn };
+}
+
+async function finalizeMatch(
+ matchId: string,
+ { home_cups, away_cups, ot_count }: { home_cups: number; away_cups: number; ot_count: number }
+): Promise<{ match: Match; downstreamReset: boolean; groupEditAfterKnockout: boolean }> {
+ assertValidScore(home_cups, away_cups, ot_count);
+
+ const existing = await pbAdmin.getMatch(matchId);
+ if (!existing) throw new Error("Match not found");
+ const tournamentId = existing.tournament.id;
+
+ if (existing.lid === -1) {
+ const source = await pbAdmin.updateMatch(matchId, {
+ end_time: new Date().toISOString(),
+ status: "ended",
+ home_cups,
+ away_cups,
+ ot_count,
+ reported_home_cups: null,
+ reported_away_cups: null,
+ reported_ot_count: null,
+ reported_by_team: null,
+ reported_by_player: null,
+ });
+
+ const all = await pbAdmin.getMatchesByTournament(tournamentId);
+ const groupEditAfterKnockout = all.some(
+ (m) => m.round >= 0 && (teamId(m.home) || teamId(m.away))
+ );
+
+ emitServerEvent({ type: "match", matchId: source.id, tournamentId });
+ return { match: source, downstreamReset: false, groupEditAfterKnockout };
+ }
+
+ const { source, downstreamReset } = await rederiveBracket(tournamentId, matchId, {
+ home_cups,
+ away_cups,
+ ot_count,
+ });
+
+ emitServerEvent({ type: "match", matchId: source.id, tournamentId });
+ return { match: source, downstreamReset, groupEditAfterKnockout: false };
+}
+
const endMatchSchema = z.object({
matchId: z.string(),
home_cups: z.number(),
@@ -469,83 +806,153 @@ export const endMatch = createServerFn()
toServerResult(async () => {
logger.info("Ending match", matchId);
- let match = await pbAdmin.getMatch(matchId);
+ const match = await pbAdmin.getMatch(matchId);
if (!match) {
throw new Error("Match not found");
}
- match = await pbAdmin.updateMatch(matchId, {
- end_time: new Date().toISOString(),
- status: "ended",
- home_cups,
- away_cups,
- ot_count,
+ return finalizeMatch(matchId, { home_cups, away_cups, ot_count });
+ })
+ );
+
+const teamPlayerIds = (team: Match["home"]): string[] =>
+ team && typeof team !== "string" ? (team.players ?? []).map((p) => p.id) : [];
+
+const reportScoreSchema = z.object({
+ matchId: z.string(),
+ home_cups: z.number(),
+ away_cups: z.number(),
+ ot_count: z.number(),
+});
+
+export const reportMatchScore = createServerFn()
+ .validator(reportScoreSchema)
+ .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
+ .handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
+ toServerResult(async () => {
+ const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
+ if (!player?.id) throw new Error("Player not found");
+
+ const match = await pbAdmin.getMatch(matchId);
+ if (!match) throw new Error("Match not found");
+ if (match.status !== "started")
+ throw new Error("Scores can only be reported once the match has started");
+
+ const onHome = teamPlayerIds(match.home).includes(player.id);
+ const onAway = teamPlayerIds(match.away).includes(player.id);
+ if (!onHome && !onAway)
+ throw new Error("You are not a player in this match");
+ if (onHome && onAway)
+ throw new Error(
+ "You are on both teams in this match and cannot report or confirm its score"
+ );
+
+ assertValidScore(home_cups, away_cups, ot_count);
+
+ const callerTeamId = onHome ? teamId(match.home) : teamId(match.away);
+
+ const hasPending =
+ match.reported_by_team != null &&
+ match.reported_home_cups != null &&
+ match.reported_away_cups != null;
+ const fromOppositeTeam =
+ hasPending && match.reported_by_team !== callerTeamId;
+ const identical =
+ hasPending &&
+ match.reported_home_cups === home_cups &&
+ match.reported_away_cups === away_cups &&
+ (match.reported_ot_count ?? 0) === ot_count;
+
+ if (fromOppositeTeam && identical) {
+ const result = await finalizeMatch(matchId, { home_cups, away_cups, ot_count });
+ return {
+ success: true as const,
+ finalized: true as const,
+ downstreamReset: result.downstreamReset,
+ groupEditAfterKnockout: result.groupEditAfterKnockout,
+ };
+ }
+
+ await pbAdmin.updateMatch(matchId, {
+ reported_home_cups: home_cups,
+ reported_away_cups: away_cups,
+ reported_ot_count: ot_count,
+ reported_by_team: callerTeamId,
+ reported_by_player: player.id,
});
- if (match.lid === -1) {
- emitServerEvent({
- type: "match",
- matchId: match.id,
- tournamentId: match.tournament.id
- });
- return match;
- }
+ emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
+ return { success: true as const, finalized: false as const };
+ })
+ );
- const matchWinner = home_cups > away_cups ? match.home : match.away;
- const matchLoser = home_cups < away_cups ? match.home : match.away;
- if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
+const matchIdSchema = z.object({ matchId: z.string() });
- const { winner, loser } = await pbAdmin.getChildMatches(matchId);
+export const confirmMatchScore = createServerFn()
+ .validator(matchIdSchema)
+ .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
+ .handler(async ({ data: { matchId }, context }) =>
+ toServerResult(async () => {
+ const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
+ if (!player?.id) throw new Error("Player not found");
- if (winner && winner.reset) {
- const awayTeamWon = match.away === matchWinner;
+ const match = await pbAdmin.getMatch(matchId);
+ if (!match) throw new Error("Match not found");
+ if (match.status !== "started")
+ throw new Error("Match is not in progress");
+ if (!match.reported_by_team)
+ throw new Error("No score has been reported yet");
+ if (match.reported_home_cups == null || match.reported_away_cups == null)
+ throw new Error("The reported score is incomplete");
- if (!awayTeamWon) {
- logger.info("Deleting reset match", {
- resetMatchId: winner.id,
- currentMatchId: match.id,
- reason: "not necessary",
- });
+ const onHome = teamPlayerIds(match.home).includes(player.id);
+ const onAway = teamPlayerIds(match.away).includes(player.id);
+ if (onHome && onAway)
+ throw new Error(
+ "You are on both teams in this match and cannot report or confirm its score"
+ );
- await pbAdmin.deleteMatch(winner.id);
- emitServerEvent({
- type: "match",
- matchId: match.id,
- tournamentId: match.tournament.id
- });
- return match;
- }
- }
+ const homeId = teamId(match.home);
+ const reportedByHome = match.reported_by_team === homeId;
+ const opposingTeam = reportedByHome ? match.away : match.home;
- if (winner) {
- await pbAdmin.updateMatch(winner.id, {
- [winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
- status:
- (winner.home_from_lid === match.lid && winner.away) ||
- (winner.away_from_lid === match.lid && winner.home)
- ? "ready"
- : "tbd",
- });
- }
+ if (!teamPlayerIds(opposingTeam).includes(player.id))
+ throw new Error("Only a player on the other team can confirm this score");
- if (loser) {
- await pbAdmin.updateMatch(loser.id, {
- [loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id,
- status:
- (loser.home_from_lid === match.lid && loser.away) ||
- (loser.away_from_lid === match.lid && loser.home)
- ? "ready"
- : "tbd",
- });
- }
+ return finalizeMatch(matchId, {
+ home_cups: match.reported_home_cups,
+ away_cups: match.reported_away_cups,
+ ot_count: match.reported_ot_count ?? 0,
+ });
+ })
+ );
- emitServerEvent({
- type: "match",
- matchId: match.id,
- tournamentId: match.tournament.id
+export const clearMatchReport = createServerFn()
+ .validator(matchIdSchema)
+ .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
+ .handler(async ({ data: { matchId }, context }) =>
+ toServerResult(async () => {
+ const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
+ if (!player?.id) throw new Error("Player not found");
+
+ const match = await pbAdmin.getMatch(matchId);
+ if (!match) throw new Error("Match not found");
+
+ const onHome = teamPlayerIds(match.home).includes(player.id);
+ const onAway = teamPlayerIds(match.away).includes(player.id);
+ if (!onHome && !onAway)
+ throw new Error("You are not a player in this match");
+
+ await pbAdmin.updateMatch(matchId, {
+ reported_home_cups: null,
+ reported_away_cups: null,
+ reported_ot_count: null,
+ reported_by_team: null,
+ reported_by_player: null,
});
- return match;
+ emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
+ return { success: true };
})
);
diff --git a/src/features/matches/types.ts b/src/features/matches/types.ts
index 7e15b25..76bac52 100644
--- a/src/features/matches/types.ts
+++ b/src/features/matches/types.ts
@@ -32,6 +32,11 @@ export interface Match {
away_seed?: number;
match_type?: MatchType;
group?: string;
+ reported_home_cups?: number;
+ reported_away_cups?: number;
+ reported_ot_count?: number;
+ reported_by_team?: string;
+ reported_by_player?: string;
}
export const matchInputSchema = z.object({
@@ -52,12 +57,17 @@ export const matchInputSchema = z.object({
is_losers_bracket: z.boolean().optional().default(false),
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
tournament: z.string().min(1),
- home: z.string().min(1).optional(),
- away: z.string().min(1).optional(),
+ home: z.string().min(1).nullable().optional(),
+ away: z.string().min(1).nullable().optional(),
home_seed: z.number().int().min(1).optional(),
away_seed: z.number().int().min(1).optional(),
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
group: z.string().optional(),
+ reported_home_cups: z.number().int().min(0).nullable().optional(),
+ reported_away_cups: z.number().int().min(0).nullable().optional(),
+ reported_ot_count: z.number().int().min(0).nullable().optional(),
+ reported_by_team: z.string().nullable().optional(),
+ reported_by_player: z.string().nullable().optional(),
});
export type MatchInput = z.infer;
diff --git a/src/features/settings/components/notifications-section.tsx b/src/features/settings/components/notifications-section.tsx
new file mode 100644
index 0000000..1e5bec5
--- /dev/null
+++ b/src/features/settings/components/notifications-section.tsx
@@ -0,0 +1,82 @@
+import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
+import { useWebPush } from "@/hooks/use-web-push";
+import toast from "@/lib/sonner";
+
+export function NotificationsSection() {
+ const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
+ useWebPush();
+
+ if (!supported || !configured) {
+ return (
+
+ Notifications
+
+ {supported
+ ? "Notifications aren't available right now."
+ : "This device doesn't support push notifications. On iOS, install the app to your Home Screen first."}
+
+
+ );
+ }
+
+ const handleToggle = async (checked: boolean) => {
+ if (checked) {
+ const ok = await enable();
+ if (ok) {
+ toast.success("Notifications enabled on this device");
+ } else if (permission === "denied") {
+ toast.error(
+ "Notifications are blocked. Enable them in your browser settings."
+ );
+ } else {
+ toast.error("Couldn't enable notifications");
+ }
+ } else {
+ const ok = await disable();
+ if (ok) toast.success("Notifications disabled on this device");
+ else toast.error("Couldn't disable notifications");
+ }
+ };
+
+ const handleTest = async () => {
+ const ok = await sendTest();
+ if (ok) toast.success("Test notification sent");
+ else toast.error("Couldn't send test notification");
+ };
+
+ return (
+
+ Notifications
+
+ handleToggle(e.currentTarget.checked)}
+ disabled={busy}
+ label="Enable notifications on this device"
+ />
+ {subscribed && (
+
+
+
+ )}
+
+
+ );
+}
+
+export default NotificationsSection;
diff --git a/src/features/tournaments/components/group-match-card.tsx b/src/features/tournaments/components/group-match-card.tsx
index e277389..42fb3b8 100644
--- a/src/features/tournaments/components/group-match-card.tsx
+++ b/src/features/tournaments/components/group-match-card.tsx
@@ -15,12 +15,14 @@ import AnimatedScore from "@/features/matches/components/animated-score";
interface GroupMatchCardProps {
match: Match;
showControls?: boolean;
+ nextUpMatchId?: string;
}
-const GroupMatchCard: React.FC = ({ match, showControls }) => {
+const GroupMatchCard: React.FC = ({ match, showControls, nextUpMatchId }) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
+ const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
const isReady = match.status === "ready";
const isStarted = match.status === "started";
const isEnded = match.status === "ended";
@@ -73,7 +75,17 @@ const GroupMatchCard: React.FC = ({ match, showControls })
return (
<>
-
+
= ({
hasKnockoutBracket,
isRegional,
groupConfig,
+ nextUpMatchId,
}) => {
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState>({});
@@ -64,35 +67,22 @@ const GroupStageView: React.FC = ({
populateKnockoutMutation.mutate({ data: { tournamentId } });
};
- const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
- if (matches.length <= 1) return matches;
+ const globalOrderRank = useMemo(() => {
+ const groupMatches = matches.filter((match) => match.round === -1 && match.group);
+ const order = computeGroupStageOrder(
+ groupMatches.map((m) => ({
+ groupId: m.group ?? "",
+ teamA: m.home?.id ?? "",
+ teamB: m.away?.id ?? "",
+ })),
+ );
- const ordered: Match[] = [];
- const remaining = [...matches];
-
- ordered.push(remaining.shift()!);
-
- while (remaining.length > 0) {
- const lastMatch = ordered[ordered.length - 1];
- const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
-
- let bestMatchIndex = remaining.findIndex((match) => {
- const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
- for (const teamId of currentTeams) {
- if (lastTeams.has(teamId)) return false;
- }
- return true;
- });
-
- if (bestMatchIndex === -1) {
- bestMatchIndex = 0;
- }
-
- ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
- }
-
- return ordered;
- };
+ const rank = new Map();
+ order.forEach((matchIdx, position) => {
+ rank.set(groupMatches[matchIdx].id, position);
+ });
+ return rank;
+ }, [matches]);
const matchesByGroup = useMemo(() => {
const map = new Map();
@@ -107,11 +97,14 @@ const GroupStageView: React.FC = ({
});
map.forEach((groupMatches, groupId) => {
- map.set(groupId, orderMatchesWithSpacing(groupMatches));
+ const sorted = [...groupMatches].sort(
+ (a, b) => (globalOrderRank.get(a.id) ?? 0) - (globalOrderRank.get(b.id) ?? 0),
+ );
+ map.set(groupId, sorted);
});
return map;
- }, [matches]);
+ }, [matches, globalOrderRank]);
const sortedGroups = useMemo(() => {
return [...groups].sort((a, b) => a.order - b.order);
@@ -461,6 +454,7 @@ const GroupStageView: React.FC = ({
key={match.id}
match={match}
showControls={showControls}
+ nextUpMatchId={nextUpMatchId}
/>
))}
diff --git a/src/features/tournaments/components/started-tournament/header.tsx b/src/features/tournaments/components/started-tournament/header.tsx
index d92d935..dfdef85 100644
--- a/src/features/tournaments/components/started-tournament/header.tsx
+++ b/src/features/tournaments/components/started-tournament/header.tsx
@@ -1,4 +1,4 @@
-import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
+import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
import { Tournament } from "../../types";
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
@@ -11,7 +11,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
);
return (
-
+
{
: undefined
}
radius="md"
- size={250}
+ size={64}
>
-
+
-
+
{tournament.location && (
-
+
-
+
{tournament.location}
)}
-
+
-
+
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
@@ -58,8 +58,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
})}
-
-
+
+
);
};
diff --git a/src/features/tournaments/components/started-tournament/index.tsx b/src/features/tournaments/components/started-tournament/index.tsx
index bf4d45a..892c012 100644
--- a/src/features/tournaments/components/started-tournament/index.tsx
+++ b/src/features/tournaments/components/started-tournament/index.tsx
@@ -1,50 +1,83 @@
import { useMemo } from "react";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
-import { Box, Divider, Stack, Text, Card, Center, Group, Indicator } from "@mantine/core";
-import { Carousel } from "@mantine/carousel";
-import carouselClasses from "./carousel.module.css";
-import ListLink from "@/components/list-link";
-import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
-import WizardOrbIcon from "@/components/wizard-orb-icon";
+import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
+import { ClockIcon } from "@phosphor-icons/react";
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
import { predictionQueries } from "@/features/predictions/queries";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
-import TeamListButton from "../upcoming-tournament/team-list-button";
-import RulesListButton from "../upcoming-tournament/rules-list-button";
-import MatchCard from "@/features/matches/components/match-card";
+import { Match } from "@/features/matches/types";
import Header from "./header";
import { Podium } from "../podium";
+import SectionHeading from "./sections/section-heading";
+import MatchCarousel from "./sections/match-carousel";
+import ScoreReportCta from "./sections/score-report-cta";
+import RecentResults from "./sections/recent-results";
+import NavGrid from "./sections/nav-grid";
+
+const teamPlayerIds = (t: Match["home"]): string[] =>
+ t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
}) => {
- const { roles } = useAuth();
+ const { user, roles } = useAuth();
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
- const startedMatches = useMemo(() =>
- tournament.matches?.filter(match => match.status === "started") || [],
- [tournament.matches]
+ const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
+
+ const liveMatches = useMemo(
+ () => matches.filter((m) => m.status === "started"),
+ [matches]
+ );
+
+ const upcomingMatches = useMemo(
+ () => matches.filter((m) => m.status === "ready" && m.home && m.away),
+ [matches]
+ );
+
+ const endedMatches = useMemo(
+ () =>
+ matches
+ .filter((m) => m.status === "ended")
+ .sort(
+ (a, b) =>
+ new Date(b.end_time).getTime() - new Date(a.end_time).getTime()
+ ),
+ [matches]
+ );
+
+ const myLiveMatches = useMemo(
+ () =>
+ liveMatches.filter(
+ (m) =>
+ !!user?.id &&
+ (teamPlayerIds(m.home).includes(user.id) ||
+ teamPlayerIds(m.away).includes(user.id))
+ ),
+ [liveMatches, user?.id]
);
const isTournamentOver = useMemo(() => {
- const matches = tournament.matches || [];
if (matches.length === 0) return false;
- const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
+ const nonByeMatches = matches.filter(
+ (match) => !(match.status === "tbd" && match.bye === true)
+ );
if (nonByeMatches.length === 0) return false;
const finalsMatch = nonByeMatches.reduce((highest, current) =>
- (!highest || current.lid > highest.lid) ? current : highest
+ !highest || current.lid > highest.lid ? current : highest
);
- return finalsMatch?.status === 'ended';
- }, [tournament.matches]);
+ return finalsMatch?.status === "ended";
+ }, [matches]);
- const hasGroupStage = useMemo(() => {
- return tournament.matches?.some((match) => match.round === -1) || false;
- }, [tournament.matches]);
+ const hasGroupStage = useMemo(
+ () => matches.some((match) => match.round === -1),
+ [matches]
+ );
const isPredictable = useMemo(
() => isTournamentPredictable(tournament),
@@ -52,8 +85,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
);
const predictionsLocked = useMemo(
- () => isPredictionLocked(tournament.matches || []),
- [tournament.matches]
+ () => isPredictionLocked(matches),
+ [matches]
);
const { data: myPrediction } = useServerQuery({
@@ -62,48 +95,42 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
});
const hasSubmitted = !!myPrediction?.prediction;
+ const hasNoMatchContent =
+ !isTournamentOver &&
+ liveMatches.length === 0 &&
+ upcomingMatches.length === 0 &&
+ endedMatches.length === 0;
+
return (
- {startedMatches.length > 0 ? (
-
-
-
-
- Live Matches{startedMatches.length > 1 && ` ยท ${startedMatches.length}`}
-
-
- 1}
- classNames={{
- indicators: carouselClasses.indicators,
- indicator: carouselClasses.indicator,
- }}
- >
- {startedMatches.map((match, index) => (
-
-
-
-
-
- ))}
-
+
+
+ {isTournamentOver && (
+
+
- ) : isTournamentOver ? (
-
-
-
- ) : (
+ )}
+
+ {liveMatches.length > 0 && (
+
+
+
+
+ )}
+
+ {upcomingMatches.length > 0 && (
+
+
+ {/* SIDE BETS SLOT โ see docs/side-bets.md */}
+
+
+ )}
+
+
+
+ {hasNoMatchContent && (
@@ -117,45 +144,18 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
)}
-
- {isAdmin && (
-
- )}
- {hasGroupStage && (
-
- )}
-
+
- {isPredictable && !predictionsLocked && (
-
- )}
- {isPredictable && (predictionsLocked || hasSubmitted) && (
-
- )}
-
-
);
};
-export default StartedTournament;
\ No newline at end of file
+export default StartedTournament;
diff --git a/src/features/tournaments/components/started-tournament/sections/match-carousel.tsx b/src/features/tournaments/components/started-tournament/sections/match-carousel.tsx
new file mode 100644
index 0000000..b4aa916
--- /dev/null
+++ b/src/features/tournaments/components/started-tournament/sections/match-carousel.tsx
@@ -0,0 +1,45 @@
+import { Box } from "@mantine/core";
+import { Carousel } from "@mantine/carousel";
+import MatchCard from "@/features/matches/components/match-card";
+import { Match } from "@/features/matches/types";
+import carouselClasses from "../carousel.module.css";
+
+interface MatchCarouselProps {
+ matches: Match[];
+}
+
+const MatchCarousel = ({ matches }: MatchCarouselProps) => {
+ if (matches.length === 1) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ 1}
+ classNames={{
+ indicators: carouselClasses.indicators,
+ indicator: carouselClasses.indicator,
+ }}
+ >
+ {matches.map((match, index) => (
+
+
+
+
+
+ ))}
+
+ );
+};
+
+export default MatchCarousel;
diff --git a/src/features/tournaments/components/started-tournament/sections/nav-grid.module.css b/src/features/tournaments/components/started-tournament/sections/nav-grid.module.css
new file mode 100644
index 0000000..09f4888
--- /dev/null
+++ b/src/features/tournaments/components/started-tournament/sections/nav-grid.module.css
@@ -0,0 +1,38 @@
+.tile {
+ -webkit-tap-highlight-color: transparent;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 14px 8px;
+ border-radius: var(--mantine-radius-md);
+ border: 1px solid var(--mantine-color-default-border);
+ background-color: var(--mantine-color-body);
+ text-align: center;
+}
+
+.icon {
+ color: var(--mantine-primary-color-filled);
+ display: flex;
+}
+
+@media (hover: hover) {
+ .tile:hover:not(:disabled) {
+ background-color: var(--mantine-color-default-hover);
+ }
+}
+
+.tile:active:not(:disabled) {
+ background-color: var(--mantine-color-default-hover);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .tile {
+ transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
+ }
+
+ .tile:active:not(:disabled) {
+ transform: scale(0.97);
+ }
+}
diff --git a/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx b/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx
new file mode 100644
index 0000000..cdd9e54
--- /dev/null
+++ b/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx
@@ -0,0 +1,164 @@
+import { lazy, Suspense } from "react";
+import {
+ Box,
+ Button,
+ Flex,
+ Loader,
+ SimpleGrid,
+ Stack,
+ Text,
+ UnstyledButton,
+} from "@mantine/core";
+import { Icon } from "@phosphor-icons/react";
+import {
+ ListDashes,
+ ListIcon,
+ TreeStructureIcon,
+ UsersIcon,
+} from "@phosphor-icons/react";
+import { useNavigate } from "@tanstack/react-router";
+import Sheet from "@/components/sheet/sheet";
+import { useSheet } from "@/hooks/use-sheet";
+import TeamList from "@/features/teams/components/team-list";
+import WizardOrbIcon from "@/components/wizard-orb-icon";
+import { Tournament } from "../../../types";
+import classes from "./nav-grid.module.css";
+
+const RulesContent = lazy(
+ () => import("../../upcoming-tournament/rules-content")
+);
+
+interface NavTileProps {
+ label: string;
+ Icon: Icon;
+ onClick: () => void;
+}
+
+/** A single square-ish nav tile with icon-over-label and press feedback. */
+const NavTile = ({ label, Icon, onClick }: NavTileProps) => (
+
+
+
+
+
+ {label}
+
+
+);
+
+interface NavGridProps {
+ tournament: Tournament;
+ isAdmin: boolean;
+ hasGroupStage: boolean;
+ isPredictable: boolean;
+ predictionsLocked: boolean;
+ hasSubmitted: boolean;
+}
+
+/**
+ * Consolidates the old stack of full-width ListLinks into a compact icon grid
+ * so it no longer dominates the page. Every original destination stays
+ * reachable with identical conditional visibility. Teams and Rules open sheets;
+ * everything else navigates.
+ */
+const NavGrid = ({
+ tournament,
+ isAdmin,
+ hasGroupStage,
+ isPredictable,
+ predictionsLocked,
+ hasSubmitted,
+}: NavGridProps) => {
+ const navigate = useNavigate();
+ const teamsSheet = useSheet();
+ const rulesSheet = useSheet();
+
+ return (
+
+
+ {isAdmin && (
+
+ navigate({ to: `/admin/tournaments/${tournament.id}` })
+ }
+ />
+ )}
+ {hasGroupStage && (
+
+ navigate({ to: `/tournaments/${tournament.id}/groups` })
+ }
+ />
+ )}
+
+ navigate({ to: `/tournaments/${tournament.id}/bracket` })
+ }
+ />
+ {isPredictable && !predictionsLocked && (
+
+ navigate({ to: `/tournaments/${tournament.id}/predictions/make` })
+ }
+ />
+ )}
+ {isPredictable && (predictionsLocked || hasSubmitted) && (
+
+ navigate({ to: `/tournaments/${tournament.id}/predictions` })
+ }
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+ );
+};
+
+export default NavGrid;
diff --git a/src/features/tournaments/components/started-tournament/sections/recent-results.tsx b/src/features/tournaments/components/started-tournament/sections/recent-results.tsx
new file mode 100644
index 0000000..0d06697
--- /dev/null
+++ b/src/features/tournaments/components/started-tournament/sections/recent-results.tsx
@@ -0,0 +1,61 @@
+import { Anchor, Box, Group, Stack } from "@mantine/core";
+import { CaretRightIcon } from "@phosphor-icons/react";
+import { useNavigate } from "@tanstack/react-router";
+import MatchCard from "@/features/matches/components/match-card";
+import { Match } from "@/features/matches/types";
+import SectionHeading from "./section-heading";
+
+interface RecentResultsProps {
+ /** Ended matches, already sorted most-recent-first. */
+ matches: Match[];
+ tournamentId: string;
+ /** How many to show inline before offering "View all results". */
+ max?: number;
+}
+
+/**
+ * Recently finished matches, newest on top, so people can look back and react
+ * (each MatchCard carries its own reaction bar). Caps the inline list and links
+ * to the full bracket when there are more results than fit.
+ */
+const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) => {
+ const navigate = useNavigate();
+
+ if (matches.length === 0) return null;
+
+ const shown = matches.slice(0, max);
+ const hasMore = matches.length > max;
+
+ return (
+
+
+ navigate({ to: `/tournaments/${tournamentId}/bracket` })
+ }
+ >
+
+ View all results
+
+
+
+ ) : undefined
+ }
+ />
+
+ {shown.map((match) => (
+
+ ))}
+
+
+ );
+};
+
+export default RecentResults;
diff --git a/src/features/tournaments/components/started-tournament/sections/score-report-cta.tsx b/src/features/tournaments/components/started-tournament/sections/score-report-cta.tsx
new file mode 100644
index 0000000..bfe7c8e
--- /dev/null
+++ b/src/features/tournaments/components/started-tournament/sections/score-report-cta.tsx
@@ -0,0 +1,86 @@
+import { Box, Group, Paper, Stack, Text } from "@mantine/core";
+import { Match } from "@/features/matches/types";
+import { MatchReport } from "@/features/bracket/components/match-report";
+import TeamAvatar from "@/components/team-avatar";
+import SectionHeading from "./section-heading";
+
+interface ScoreReportCtaProps {
+ /** Started matches the current user is playing in. */
+ matches: Match[];
+}
+
+const teamName = (t: Match["home"]): string =>
+ t && typeof t !== "string" ? t.name : "TBD";
+
+/**
+ * Top-of-home call-to-action for a participant whose match is live. Composes
+ * the existing so reporting/confirming a score is one tap from
+ * the home screen instead of buried in the bracket. Renders nothing when the
+ * user isn't playing in a started match.
+ */
+const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
+ if (matches.length === 0) return null;
+
+ return (
+
+
+
+ {matches.map((match) => (
+
+
+
+
+ {match.home && typeof match.home !== "string" && (
+
+ )}
+
+ {teamName(match.home)}
+
+
+
+ vs
+
+
+
+ {teamName(match.away)}
+
+ {match.away && typeof match.away !== "string" && (
+
+ )}
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default ScoreReportCta;
diff --git a/src/features/tournaments/components/started-tournament/sections/section-heading.tsx b/src/features/tournaments/components/started-tournament/sections/section-heading.tsx
new file mode 100644
index 0000000..4e30fe0
--- /dev/null
+++ b/src/features/tournaments/components/started-tournament/sections/section-heading.tsx
@@ -0,0 +1,40 @@
+import { Group, Indicator, Text } from "@mantine/core";
+
+interface SectionHeadingProps {
+ label: string;
+ /** Show a pulsing red "live" dot before the label. */
+ live?: boolean;
+ /** Appended as "ยท N" after the label when greater than 1. */
+ count?: number;
+ /** Optional right-aligned action (e.g. a "View all" link). */
+ action?: React.ReactNode;
+}
+
+/**
+ * Compact, uppercase section label used across the started-tournament home
+ * surfaces. Mirrors the original "Live Matches" heading treatment.
+ */
+const SectionHeading = ({ label, live, count, action }: SectionHeadingProps) => {
+ return (
+
+
+ {live && (
+
+ )}
+
+ {label}
+ {count && count > 1 ? ` ยท ${count}` : ""}
+
+
+ {action}
+
+ );
+};
+
+export default SectionHeading;
diff --git a/src/features/tournaments/components/started-tournament/skeleton.tsx b/src/features/tournaments/components/started-tournament/skeleton.tsx
index 12cfa81..71cce61 100644
--- a/src/features/tournaments/components/started-tournament/skeleton.tsx
+++ b/src/features/tournaments/components/started-tournament/skeleton.tsx
@@ -1,70 +1,78 @@
-import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
+import { Box, Card, Divider, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
+
+const MatchCardSkeleton = ({ minWidth }: { minWidth: string }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
const StartedTournamentSkeleton = () => {
return (
- {/* Header skeleton */}
-
-
-
-
+ {/* Compact header skeleton */}
+
+
+
+
+
+
+
- {/* Match carousel skeleton */}
+ {/* Live carousel skeleton */}
-
- {Array.from({ length: 2 }).map((_, index) => (
-
-
- {/* Match header */}
-
-
-
-
-
- {/* Teams */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
+
+
+
+
- {/* Actions section skeleton */}
+ {/* Recently finished skeleton */}
-
-
-
-
-
-
+
+
+
+
+
+ {/* Nav grid skeleton */}
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
);
};
-export default StartedTournamentSkeleton;
\ No newline at end of file
+export default StartedTournamentSkeleton;
diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts
index ab9f523..e7f393b 100644
--- a/src/features/tournaments/server.ts
+++ b/src/features/tournaments/server.ts
@@ -10,6 +10,7 @@ import { emitServerEvent } from "@/lib/events/emitter";
import brackets from "@/features/bracket/utils";
import { MatchInput } from "@/features/matches/types";
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
+import { orderGroupStageMatches } from "./utils/match-queue";
export const listTournaments = createServerFn()
.middleware([superTokensFunctionMiddleware])
@@ -69,12 +70,6 @@ export const enrollTeam = createServerFn()
const team = await pbAdmin.getTeam(teamId);
if (!team) { throw new Error('Team not found'); }
- //const isPlayerOnTeam = team.players?.some(player => player.id === userId);
-
- //if (!isPlayerOnTeam && !isAdmin) {
- // throw new Error('You do not have permission to enroll this team');
- //}
-
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
for (const player of team.players || []) {
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
@@ -816,7 +811,8 @@ export const generateGroupStage = createServerFn()
groupStageMatches.push({
lid: -1,
round: -1,
- order: groupStageMatches.length + 1,
+ order: 0,
+
reset: false,
bye: false,
home: teamIds[i],
@@ -838,6 +834,14 @@ export const generateGroupStage = createServerFn()
}
}
+ const orderedGroupMatches = orderGroupStageMatches(
+ groupStageMatches,
+ (m) => ({ groupId: m.group, teamA: m.home, teamB: m.away })
+ );
+ orderedGroupMatches.forEach((match, index) => {
+ match.order = index + 1;
+ });
+
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
diff --git a/src/features/tournaments/utils/match-queue.ts b/src/features/tournaments/utils/match-queue.ts
new file mode 100644
index 0000000..6eb47b8
--- /dev/null
+++ b/src/features/tournaments/utils/match-queue.ts
@@ -0,0 +1,135 @@
+export interface OrderableMatch {
+ groupId: string;
+ teamA: string;
+ teamB: string;
+}
+
+const BYE = "__BYE__";
+
+function pairKey(a: string, b: string): string {
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
+}
+
+function circleMethodRoundIndex(teams: string[]): Map {
+ const sorted = [...teams].sort();
+ const arr = [...sorted];
+ if (arr.length % 2 === 1) arr.push(BYE);
+
+ const n = arr.length;
+ const result = new Map();
+ if (n < 2) return result;
+
+ const rounds = n - 1;
+ const half = n / 2;
+ let list = [...arr];
+
+ for (let r = 0; r < rounds; r++) {
+ for (let i = 0; i < half; i++) {
+ const a = list[i];
+ const b = list[n - 1 - i];
+ if (a !== BYE && b !== BYE) {
+ result.set(pairKey(a, b), r);
+ }
+ }
+ list = [list[0], list[n - 1], ...list.slice(1, n - 1)];
+ }
+
+ return result;
+}
+
+interface AnnotatedMatch {
+ idx: number;
+ groupId: string;
+ teamA: string;
+ teamB: string;
+ roundIndex: number;
+ key: string;
+}
+
+function isBetter(a: number[], aKey: string, b: number[], bKey: string): boolean {
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return a[i] > b[i];
+ }
+ return aKey < bKey;
+}
+
+export function computeGroupStageOrder(matches: OrderableMatch[]): number[] {
+ const n = matches.length;
+ if (n <= 1) return matches.map((_, i) => i);
+
+ const groupTeams = new Map>();
+ for (const m of matches) {
+ let set = groupTeams.get(m.groupId);
+ if (!set) {
+ set = new Set();
+ groupTeams.set(m.groupId, set);
+ }
+ set.add(m.teamA);
+ set.add(m.teamB);
+ }
+
+ const roundIndexByGroup = new Map>();
+ for (const [groupId, teams] of groupTeams) {
+ roundIndexByGroup.set(groupId, circleMethodRoundIndex([...teams]));
+ }
+
+ const items: AnnotatedMatch[] = matches.map((m, idx) => {
+ const round = roundIndexByGroup.get(m.groupId)?.get(pairKey(m.teamA, m.teamB));
+ return {
+ idx,
+ groupId: m.groupId,
+ teamA: m.teamA,
+ teamB: m.teamB,
+ roundIndex: round ?? Number.MAX_SAFE_INTEGER,
+ key: `${m.groupId}|${pairKey(m.teamA, m.teamB)}`,
+ };
+ });
+
+ const remaining = new Set(items.map((it) => it.idx));
+ const itemByIdx = new Map(items.map((it) => [it.idx, it]));
+
+ const order: number[] = [];
+ const lastPlayed = new Map();
+ const NEVER = -1 - n;
+ let prevTeams = new Set();
+ let prevGroup: string | null = null;
+
+ for (let pos = 0; pos < n; pos++) {
+ let best: AnnotatedMatch | null = null;
+ let bestScore: number[] | null = null;
+
+ for (const idx of remaining) {
+ const it = itemByIdx.get(idx)!;
+
+ const adjacent = prevTeams.has(it.teamA) || prevTeams.has(it.teamB) ? 1 : 0;
+ const gapA = pos - (lastPlayed.get(it.teamA) ?? NEVER);
+ const gapB = pos - (lastPlayed.get(it.teamB) ?? NEVER);
+ const gap = Math.min(gapA, gapB);
+ const sameGroup = prevGroup !== null && it.groupId === prevGroup ? 1 : 0;
+ const score = [-adjacent, gap, -sameGroup, -it.roundIndex];
+
+ if (best === null || isBetter(score, it.key, bestScore!, best.key)) {
+ best = it;
+ bestScore = score;
+ }
+ }
+
+ order.push(best!.idx);
+ remaining.delete(best!.idx);
+ lastPlayed.set(best!.teamA, pos);
+ lastPlayed.set(best!.teamB, pos);
+ prevTeams = new Set([best!.teamA, best!.teamB]);
+ prevGroup = best!.groupId;
+ }
+
+ return order;
+}
+
+export function orderGroupStageMatches(
+ items: T[],
+ accessor: (item: T) => OrderableMatch,
+): T[] {
+ const orderable = items.map(accessor);
+ const order = computeGroupStageOrder(orderable);
+ return order.map((idx) => items[idx]);
+}
diff --git a/src/hooks/use-web-push.ts b/src/hooks/use-web-push.ts
new file mode 100644
index 0000000..a326017
--- /dev/null
+++ b/src/hooks/use-web-push.ts
@@ -0,0 +1,144 @@
+import { useCallback, useEffect, useState } from "react";
+
+const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY as
+ | string
+ | undefined;
+
+function urlBase64ToBuffer(base64String: string): ArrayBuffer {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+ const raw = atob(base64);
+ const buffer = new ArrayBuffer(raw.length);
+ const output = new Uint8Array(buffer);
+ for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i);
+ return buffer;
+}
+
+export type PushPermission = "default" | "granted" | "denied";
+
+export interface UseWebPush {
+ supported: boolean;
+ configured: boolean;
+ permission: PushPermission;
+ subscribed: boolean;
+ busy: boolean;
+ enable: () => Promise;
+ disable: () => Promise;
+ sendTest: () => Promise;
+}
+
+async function getRegistration(): Promise {
+ if (!("serviceWorker" in navigator)) return null;
+ return (
+ (await navigator.serviceWorker.getRegistration()) ??
+ (await navigator.serviceWorker.ready.catch(() => null))
+ );
+}
+
+export function useWebPush(): UseWebPush {
+ const [supported, setSupported] = useState(false);
+ const [permission, setPermission] = useState("default");
+ const [subscribed, setSubscribed] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const configured = Boolean(VAPID_PUBLIC_KEY);
+
+ useEffect(() => {
+ const isSupported =
+ typeof window !== "undefined" &&
+ "Notification" in window &&
+ "PushManager" in window &&
+ "serviceWorker" in navigator;
+ setSupported(isSupported);
+ if (!isSupported) return;
+
+ setPermission(Notification.permission as PushPermission);
+
+ (async () => {
+ const reg = await getRegistration();
+ const sub = await reg?.pushManager.getSubscription();
+ setSubscribed(Boolean(sub));
+ })();
+ }, []);
+
+ const enable = useCallback(async (): Promise => {
+ if (!supported || !configured || !VAPID_PUBLIC_KEY) return false;
+ setBusy(true);
+ try {
+ const perm = await Notification.requestPermission();
+ setPermission(perm as PushPermission);
+ if (perm !== "granted") return false;
+
+ const reg = await getRegistration();
+ if (!reg) return false;
+
+ let sub = await reg.pushManager.getSubscription();
+ if (!sub) {
+ sub = await reg.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToBuffer(VAPID_PUBLIC_KEY),
+ });
+ }
+
+ const res = await fetch("/api/push/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ subscription: sub.toJSON() }),
+ });
+ if (!res.ok) return false;
+
+ setSubscribed(true);
+ return true;
+ } catch {
+ return false;
+ } finally {
+ setBusy(false);
+ }
+ }, [supported, configured]);
+
+ const disable = useCallback(async (): Promise => {
+ if (!supported) return false;
+ setBusy(true);
+ try {
+ const reg = await getRegistration();
+ const sub = await reg?.pushManager.getSubscription();
+ if (sub) {
+ await fetch("/api/push/unsubscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ endpoint: sub.endpoint }),
+ }).catch(() => {});
+ await sub.unsubscribe().catch(() => {});
+ }
+ setSubscribed(false);
+ return true;
+ } catch {
+ return false;
+ } finally {
+ setBusy(false);
+ }
+ }, [supported]);
+
+ const sendTest = useCallback(async (): Promise => {
+ setBusy(true);
+ try {
+ const res = await fetch("/api/push/test", { method: "POST" });
+ return res.ok;
+ } catch {
+ return false;
+ } finally {
+ setBusy(false);
+ }
+ }, []);
+
+ return {
+ supported,
+ configured,
+ permission,
+ subscribed,
+ busy,
+ enable,
+ disable,
+ sendTest,
+ };
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index d019a61..5d4cf2e 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -17,3 +17,12 @@ export const serverConfig = {
spotifyClientId: process.env.VITE_SPOTIFY_CLIENT_ID || '',
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || '',
}
+
+export const vapidConfig = {
+ publicKey: process.env.VAPID_PUBLIC_KEY || '',
+ privateKey: process.env.VAPID_PRIVATE_KEY || '',
+ subject: process.env.VAPID_SUBJECT || 'mailto:admin@flexxon.app',
+}
+
+export const isPushConfigured = () =>
+ Boolean(vapidConfig.publicKey && vapidConfig.privateKey)
diff --git a/src/lib/pocketbase/client.ts b/src/lib/pocketbase/client.ts
index 8e61df8..fe548da 100644
--- a/src/lib/pocketbase/client.ts
+++ b/src/lib/pocketbase/client.ts
@@ -8,6 +8,7 @@ import { createActivitiesService } from "./services/activities";
import { createBadgesService } from "./services/badges";
import { createGroupsService } from "./services/groups";
import { createPredictionsService } from "./services/predictions";
+import { createPushService } from "./services/push";
class PocketBaseAdminClient {
private pb: PocketBase;
@@ -50,6 +51,7 @@ class PocketBaseAdminClient {
Object.assign(this, createBadgesService(this.pb));
Object.assign(this, createGroupsService(this.pb));
Object.assign(this, createPredictionsService(this.pb));
+ Object.assign(this, createPushService(this.pb));
this.authPromise = this.authenticate();
this.authPromise.then(() => {
@@ -129,7 +131,8 @@ interface AdminClient
ReturnType,
ReturnType,
ReturnType,
- ReturnType {
+ ReturnType,
+ ReturnType {
authPromise: Promise;
}
diff --git a/src/lib/pocketbase/services/matches.ts b/src/lib/pocketbase/services/matches.ts
index 6b739c4..a2a3a8c 100644
--- a/src/lib/pocketbase/services/matches.ts
+++ b/src/lib/pocketbase/services/matches.ts
@@ -5,6 +5,10 @@ import { transformMatch } from "../util/transform-types";
export function createMatchesService(pb: PocketBase) {
return {
+ createBatch() {
+ return pb.createBatch();
+ },
+
async getMatch(id: string): Promise {
const result = await pb.collection("matches").getOne(id, {
expand: "tournament, home, away, home.players, away.players",
@@ -12,7 +16,6 @@ export function createMatchesService(pb: PocketBase) {
return transformMatch(result);
},
- // match Ids where the current lid is home_from_lid or away_from_lid
async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> {
const match = await this.getMatch(matchId);
if (!match) throw new Error("Match not found")
@@ -32,7 +35,6 @@ export function createMatchesService(pb: PocketBase) {
},
async createMatch(data: MatchInput): Promise {
- // logger.info("PocketBase | Creating match", data);
const result = await pb.collection("matches").create(data);
return result;
},
@@ -142,6 +144,15 @@ export function createMatchesService(pb: PocketBase) {
return results.map(match => transformMatch(match));
},
+ async getMatchesByTournament(tournamentId: string): Promise {
+ const results = await pb.collection("matches").getFullList({
+ filter: `tournament = "${tournamentId}"`,
+ expand: "tournament, home, away, home.players, away.players",
+ sort: "lid",
+ });
+ return results.map(match => transformMatch(match));
+ },
+
async getMatchesByGroup(groupId: string): Promise {
logger.info("PocketBase | Getting matches for group", { groupId });
diff --git a/src/lib/pocketbase/services/push.ts b/src/lib/pocketbase/services/push.ts
new file mode 100644
index 0000000..e3a4eb2
--- /dev/null
+++ b/src/lib/pocketbase/services/push.ts
@@ -0,0 +1,71 @@
+import PocketBase from "pocketbase";
+
+export interface PushSubscriptionRecord {
+ id: string;
+ player: string;
+ endpoint: string;
+ p256dh: string;
+ auth: string;
+ user_agent?: string;
+ created: string;
+ updated: string;
+}
+
+export interface PushSubscriptionInput {
+ player: string;
+ endpoint: string;
+ p256dh: string;
+ auth: string;
+ user_agent?: string;
+}
+
+export const createPushService = (pb: PocketBase) => ({
+ async listPushSubscriptionsByPlayer(
+ playerId: string
+ ): Promise {
+ return await pb
+ .collection("push_subscriptions")
+ .getFullList({
+ filter: `player = "${playerId}"`,
+ });
+ },
+
+ async getPushSubscriptionByEndpoint(
+ endpoint: string
+ ): Promise {
+ try {
+ return await pb
+ .collection("push_subscriptions")
+ .getFirstListItem(
+ `endpoint = "${endpoint}"`
+ );
+ } catch {
+ return null;
+ }
+ },
+
+ async upsertPushSubscription(
+ data: PushSubscriptionInput
+ ): Promise {
+ const existing = await this.getPushSubscriptionByEndpoint(data.endpoint);
+ if (existing) {
+ return await pb
+ .collection("push_subscriptions")
+ .update(existing.id, data);
+ }
+ return await pb
+ .collection("push_subscriptions")
+ .create(data);
+ },
+
+ async deletePushSubscriptionByEndpoint(endpoint: string): Promise {
+ const existing = await this.getPushSubscriptionByEndpoint(endpoint);
+ if (existing) {
+ await pb.collection("push_subscriptions").delete(existing.id);
+ }
+ },
+
+ async deletePushSubscriptionById(id: string): Promise {
+ await pb.collection("push_subscriptions").delete(id);
+ },
+});
diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts
index 900ccdd..fd4c79d 100644
--- a/src/lib/pocketbase/util/transform-types.ts
+++ b/src/lib/pocketbase/util/transform-types.ts
@@ -6,9 +6,6 @@ import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/
import { Prediction } from "@/features/predictions/types";
import { Activity } from "../services/activities";
-// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
-// this file transforms raw pocketbase results to our types
-
export function transformPlayerInfo(record: any): PlayerInfo {
return {
id: record.id,
@@ -58,6 +55,11 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
away_seed: record.away_seed,
match_type: record.match_type,
group: record.group,
+ reported_home_cups: record.reported_home_cups ?? undefined,
+ reported_away_cups: record.reported_away_cups ?? undefined,
+ reported_ot_count: record.reported_ot_count ?? undefined,
+ reported_by_team: record.reported_by_team || undefined,
+ reported_by_player: record.reported_by_player || undefined,
};
}
diff --git a/src/lib/push/index.ts b/src/lib/push/index.ts
new file mode 100644
index 0000000..a6e1f71
--- /dev/null
+++ b/src/lib/push/index.ts
@@ -0,0 +1,111 @@
+import webpush from "web-push";
+import { vapidConfig, isPushConfigured } from "@/lib/config";
+import { pbAdmin } from "@/lib/pocketbase/client";
+import { logger } from "@/lib/logger";
+import type { PushSubscriptionRecord } from "@/lib/pocketbase/services/push";
+
+export interface PushPayload {
+ title: string;
+ body: string;
+ /** Path (or absolute URL) to open/focus when the notification is clicked. */
+ url?: string;
+ /** Optional icon override; defaults to the app icon in the SW handler. */
+ icon?: string;
+ /** Optional tag to collapse/replace notifications of the same kind. */
+ tag?: string;
+}
+
+let vapidInitialized = false;
+
+function ensureVapid(): boolean {
+ if (!isPushConfigured()) {
+ logger.warn(
+ "Push | VAPID keys not configured (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY); skipping send"
+ );
+ return false;
+ }
+ if (!vapidInitialized) {
+ webpush.setVapidDetails(
+ vapidConfig.subject,
+ vapidConfig.publicKey,
+ vapidConfig.privateKey
+ );
+ vapidInitialized = true;
+ }
+ return true;
+}
+
+/**
+ * Low-level: send a single push to one subscription. Throws on transport
+ * errors. Callers that persist subscriptions should handle 404/410 (dead
+ * endpoint) themselves โ see sendPushToPlayer.
+ */
+export async function sendPush(
+ subscription: {
+ endpoint: string;
+ keys: { p256dh: string; auth: string };
+ },
+ payload: PushPayload
+): Promise {
+ if (!ensureVapid()) return;
+ await webpush.sendNotification(subscription, JSON.stringify(payload));
+}
+
+/**
+ * Send a push to every device a player has subscribed. Dead subscriptions
+ * (HTTP 404/410 from the push service) are pruned from storage automatically.
+ *
+ * This is the seam that real product triggers will call LATER, e.g.:
+ * - a match the player is in starts (src/lib/pocketbase/services/matches.ts)
+ * - a free-agent claim involving the player (src/lib/pocketbase/services/free-agents ...)
+ * - a badge is awarded to the player (src/lib/pocketbase/services/badges.ts)
+ * NONE of these are wired yet โ this foundation pass only exposes the seam
+ * plus a manual "test notification" path.
+ */
+export async function sendPushToPlayer(
+ playerId: string,
+ payload: PushPayload
+): Promise<{ sent: number; failed: number; pruned: number }> {
+ if (!ensureVapid()) return { sent: 0, failed: 0, pruned: 0 };
+
+ const subscriptions: PushSubscriptionRecord[] =
+ await pbAdmin.listPushSubscriptionsByPlayer(playerId);
+
+ let sent = 0;
+ let failed = 0;
+ let pruned = 0;
+
+ await Promise.all(
+ subscriptions.map(async (sub) => {
+ try {
+ await sendPush(
+ {
+ endpoint: sub.endpoint,
+ keys: { p256dh: sub.p256dh, auth: sub.auth },
+ },
+ payload
+ );
+ sent++;
+ } catch (error: any) {
+ const status = error?.statusCode;
+ if (status === 404 || status === 410) {
+ // Subscription no longer valid โ prune it.
+ try {
+ await pbAdmin.deletePushSubscriptionById(sub.id);
+ pruned++;
+ } catch (delError) {
+ logger.error("Push | Failed to prune dead subscription", delError);
+ }
+ } else {
+ failed++;
+ logger.error(
+ `Push | Failed to send to endpoint (status ${status ?? "?"})`,
+ error?.body ?? error
+ );
+ }
+ }
+ })
+ );
+
+ return { sent, failed, pruned };
+}