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} + + + + + + + + + + + ) : ( + + )} + + ); + + 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} + + + + + + + + {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 }; +}