diff --git a/docs/flxn-replay.md b/docs/flxn-replay.md new file mode 100644 index 0000000..f06b793 --- /dev/null +++ b/docs/flxn-replay.md @@ -0,0 +1,78 @@ +# FLXN Replay — Design Spec (v1, locked) + +**Status:** design locked, not yet built. The tournament recap feature: a **player-owned recap** and an **overall tournament recap**, viewable **retroactively** from any completed tournament. Content is shaped to render into a **shareable card layout** (the owner's friend is producing the SVG/PNG graphic template — we spec the payload, not the artwork). + +**No new collection.** Everything below is derivable at read time from existing data — the same philosophy as the podium/placement derivation in `src/lib/pocketbase/util/transform-types.ts` (`transformTournament` computes `isComplete`, `first/second/third_place` purely from ended matches). Replay is a pair of server fns that compute a recap payload from data we already store. + +## Availability +- A tournament has a Replay when it's **complete**: every non-bye match `status === "ended"` — reuse the exact `isComplete` derivation from `transformTournament` (`src/lib/pocketbase/util/transform-types.ts:218-219`). +- **Overall recap is public** (any authed user). **Player recap** exists for every enrolled player; the "View Replay" entry shows participants their own recap. +- Retroactive by construction: old completed tournaments get Replays for free since nothing is written at tournament end. + +## Data sources (all existing) +- **Matches** — `status: "ended"`, `home_cups`/`away_cups`, `ot_count`, `start_time`/`end_time`, `home_seed`/`away_seed`, `round`, `lid`, `order`, `is_losers_bracket`, `reset`, `bye` (`src/features/matches/types.ts`, transformed at `src/lib/pocketbase/util/transform-types.ts:30`). `pbAdmin.getTournament(id)` already returns matches + teams expanded, plus `team_stats` from the `team_stats_per_tournament` view (`src/lib/pocketbase/services/tournaments.ts:21`). +- **Stats views** — `team_stats_per_tournament` gives per-team W/L, cups for/against, margins for this event (mapped onto `tournament.team_stats` in `transformTournament`). Global `player_stats` / `player_mainline_stats` / `player_regional_stats` views exist (`pb_migrations/1783926000_optimized_player_stats_views.js`) but are all-time; per-event player numbers come from the tournament's own matches (teams are 2-player, so a player's event record == their team's record). +- **Reactions** — `reactions` collection `{match, player, emoji}` (`src/lib/pocketbase/services/reactions.ts`). Currently only fetched per match; add one service method fetching all reactions for a tournament via filter `match.tournament = ""`. +- **Predictions** — `computePredictionScore` (points, correct, `predictedChampionId`) in `src/features/predictions/utils.ts` and `getPredictionsLeaderboard` in `src/features/predictions/server.ts` give points / rank / called-the-champ directly. +- **Badges** — `badge_progress` (`earned` boolean, no per-tournament attribution) — see open questions. + +## Player recap (content + derivation) +All from the completed tournament's ended matches involving the player's team (find the team by scanning `tournament.teams[].players` for the player id): +- **W–L + win %** — team's rows from `tournament.team_stats`, or count from matches. +- **Cups sunk / conceded** — `total_cups_made` / `total_cups_against` from `team_stats`. +- **Biggest blowout margin** — max `|home_cups - away_cups|` across their wins, with opponent. +- **Longest win streak** — sort their matches by `order` (fallback `end_time`), scan for the longest run of wins. +- **Nemesis** — the team that ended their tournament (opponent in their final loss; in double-elim that's their losers-bracket exit), or if undefeated/champion, worst H2H by aggregate cup differential. +- **Victim** — opponent with the biggest aggregate cup differential in the player's favor across H2H games. +- **OT games survived** — their ended matches with `ot_count >= 1`; show count and OT record. +- **Final placement + podium** — top 3 from the existing podium derivation; below that, label by exit round ("Eliminated in Match 12" / round name via `getMatchLabel`-style logic) rather than a fake numeric rank. +- **Partner chemistry** — teammate (the other id on the roster) + combined record; identical to team record, framed as "You and X went 4–1 together". +- **Prediction result** — points / rank / champion-call from the predictions leaderboard; omit the block if they didn't submit. +- **Most-reacted match** — their match with the most reaction rows; include the top emoji. +- **Badges earned this event** — see open questions; v1 approach below. + +## Overall recap (content + derivation) +- **Champion + podium** — existing `first/second/third_place` derivation. +- **Biggest upset** — among ended matches where both `home_seed` and `away_seed` are set, the win with the largest (winner seed − loser seed) gap. +- **Highest-scoring match** — max `home_cups + away_cups`. **Closest** — min nonzero margin (tie-break: more total cups). **Longest** — max `ot_count` (tie-break: `end_time - start_time` when both set). +- **Most cups in one game (single team)** — max of `home_cups`/`away_cups` with team + match. +- **Totals** — Σ cups, count of ended matches, count with `ot_count >= 1` (+ total OT periods). +- **MVP** — best `team_stats` row by win %, then margin (cup differential); surfaced as the two players of that team (or the champion team — tunable). +- **Most-reacted moment** — match with most reactions overall + top emoji. +- **Prediction-pool winner** — top of `getPredictionsLeaderboard`; omit if nobody predicted. +- **Team count / attendance** — `tournament.teams.length` / unique players across rosters. + +## Share-card layout +- The recap payload is a list of **discrete stat blocks** (`{ key, label, value, sublabel?, team?/player?, matchRef? }`) so the external SVG/PNG template can consume the same JSON the app renders — don't bake copy into components. +- In-app, render as a vertical stack of card sections (hero: placement/champion; then blocks), constrained to a portrait share-card aspect so what you see is what the graphic shows. +- v1 sharing: the friend's template consumes the payload out-of-band; an in-app "Share" (Web Share API / rendered PNG) is explicitly **not** in v1 scope — just keep the payload shape stable. + +## Entry points +- `TournamentStats` (`src/features/tournaments/components/tournament-stats.tsx`) already renders the completed-tournament overview with `ListLink` rows ("View Bracket", "View Predictions" ~lines 178-196). Add **"View Replay"** there, gated on the same `isComplete` memo already computed in that file. It links to the overall recap; inside, a participant sees a "Your Replay" hero button to their own player recap. + +## Reuses / dependencies +- `pbAdmin.getTournament` (one fetch covers matches, teams, team_stats), podium derivation, predictions scoring + leaderboard, reactions collection, `ListLink`, `SwipeableTabs`/sheet patterns, `useServerSuspenseQuery`. +- No dependency on any Wave 2/3 feature; ships standalone. + +## Implementation notes for a fresh agent (build this cold) +Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/flxn-replay.md` (this file) fully first, then: +- **No migration.** Replay writes nothing. Do not add a collection. +- **New feature dir:** `src/features/replay/{types.ts, server.ts, queries.ts, utils.ts, components/}`. Keep every derivation (streaks, nemesis/victim, upset, superlatives) as pure functions over `Match[]`/`TournamentTeamStats[]` in `utils.ts`, unit-testable like `src/features/predictions/utils.ts`. +- **Server fns:** two — `getTournamentRecap(tournamentId)` and `getPlayerRecap({tournamentId, playerId})` — following `createServerFn().validator(zod).middleware([...]).handler(...)` + `toServerResult`, with `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])`; copy the shape of `getPredictionsLeaderboard` in `src/features/predictions/server.ts` (it already does getTournamentOrThrow + derive-from-matches). Resolve the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)`. Throw if the tournament isn't complete (reuse the `isComplete` logic; export it from a shared util rather than copy-pasting a third time — it already exists twice in `transform-types.ts`). +- **Reactions per tournament:** add `getReactionsForTournament(tournamentId)` to `src/lib/pocketbase/services/reactions.ts` — `getFullList({ filter: \`match.tournament="${id}"\` })` — and aggregate counts per match in `utils.ts`. +- **Prediction block:** call the existing prediction server logic (or its underlying service + `computePredictionScore`) inside the recap fn; don't re-implement scoring. +- **Badges block (v1):** fetch the player's `badge_progress` where `earned = true` and `updated` falls within `[tournament.start_time, tournament.end_time + 24h]` — attribution by timestamp. This is best-effort (see open questions); render nothing when empty. +- **Routes:** `src/app/routes/_authed/tournaments/$id.replay.tsx` (overall) and `src/app/routes/_authed/tournaments/$id.replay_.$playerId.tsx` (player), mirroring `$id.predictions.tsx` / `$id.predictions_.$playerId.tsx` exactly (same `createFileRoute` + loader header + Suspense pattern; note sub-routes use `$id`, only the profile route uses `$tournamentId`). +- **Client data:** `useServerSuspenseQuery` with a `replayKeys`/`replayQueries` module copying `src/features/predictions/queries.ts`. Data is immutable post-completion — no SSE wiring, no invalidation, long `staleTime` is fine. +- **Entry point:** in `tournament-stats.tsx`, add `{isComplete && }` next to the existing View Bracket/Predictions links. Skip it for `tournament.regional` (old regional data is flagged unreliable in that same file). +- **Design system:** Mantine 8 + app theme (`src/lib/mantine/mantine-provider.tsx`: `defaultRadius: "sm"`, primary = user accent, press feedback via the `flxn-press` activeClassName). The recap should feel like a highlight reel, not a table — but tasteful motion only (iOS-like easing, 120–350ms), accent color reserved for the hero stat per card, and match neighboring components rather than inventing a new look. Reuse `TeamAvatar`/`PlayerAvatar` in stat blocks. +- **Toasts:** none needed (read-only feature); errors surface via the route's error boundary. + +## Tunables / open questions +- **Badge attribution** is the one shaky derivation: `badge_progress.earned` is a boolean with no event linkage, and retroactive/recomputed badges (`migrateBadgeProgress`) break timestamp inference. v1 = timestamp window (above); the clean fix is re-running badge criteria with/without this tournament's matches and diffing — decide if that's worth it before building. +- **Biggest upset without seeds:** group-stage matches (`round === -1`) and some formats lack `home_seed`/`away_seed`; v1 skips seedless matches. Fall back to group placement later? +- **Player recap visibility:** owner-only vs. public like `$id.predictions_.$playerId.tsx`? Default: viewable by any participant (it's all derivable from public match data anyway), but the entry point only advertises your own. +- **"OT games survived"** — count OT games played, or only OT wins? Default: played, with the win/loss split shown. +- **MVP definition** — best win % then margin (as specced) vs. simply the champions. Revisit after seeing real output. +- **Regional tournaments** — excluded in v1 (data quality); could be enabled per-tournament later. +- **Non-podium placement copy** — exit-round label vs. numeric rank; locked to label for v1. diff --git a/docs/free-agents.md b/docs/free-agents.md new file mode 100644 index 0000000..4504872 --- /dev/null +++ b/docs/free-agents.md @@ -0,0 +1,91 @@ +# Free-Agent Matchmaking Board — Design Spec (v1, locked) + +**Status:** design locked, not yet built. Extends the existing free-agent enrollment flow; the admin random-pairing flow stays as the fallback for anyone unmatched. + +Players who sign up without a team ("free agents") can see each other — with stats and badges — and **claim a partner**. The claimed player must **confirm** (two-sided handshake, mirroring the score-report Confirm pattern). On mutual accept the app creates the team for them. The admin still arbitrates leftovers. + +## What exists today (baseline) +- `free_agents` collection (`pbc_2929550049`): `{ player → players, tournament → tournaments, phone }`. Created in `pb_migrations/1758388728_created_free_agents.js`, tournament relation added in `1758402128_updated_free_agents.js`. No partner/status field, no claim concept. +- Service: `enrollFreeAgent` / `unenrollFreeAgent` / `getFreeAgents` in `src/lib/pocketbase/services/tournaments.ts` (~L189–216); `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` returns `{ id, phone, player }`. +- Server fns in `src/features/tournaments/server.ts`: `getFreeAgents` (L109, any authed user — note it currently returns phones for everyone), `enrollFreeAgent` (L116), `unenrollFreeAgent` (L131). All emit `{ type: "tournament", tournamentId }`. +- UI: `src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx` (enroll sheet) and `enrolled-free-agent.tsx` (the pool view). **Regional agents can't see the pool at all** — the `isRegional` branch shows only your own card with "Partners will be randomly assigned". Non-regional agents see a plain name + phone list. No stats, no badges, no actions. +- Admin fallback: `src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx` → `useGenerateRandomTeams` / `useConfirmTeamAssignments` → `generateRandomTeams` (L146) and `confirmTeamAssignments` (L326) in `src/features/tournaments/server.ts`. Confirm finds-or-creates a `private: true` team per pair, `enrollTeam`s it, then `unenrollFreeAgent`s both players. +- The two-sided confirm precedent: `reportMatchScore` / `confirmMatchScore` in `src/features/matches/server.ts` (~L828–928). Pending state lives **on the record itself** (`reported_by_team`, `reported_home_cups`, …), the other side confirms, and a cross-report with identical values auto-finalizes. + +## The board +Replaces the plain list inside `enrolled-free-agent.tsx` for **both** regional and mainline tournaments (the regional visibility gate is relaxed — see below). Each agent renders as a card: +- `PlayerAvatar` + name. +- **Stats strip:** matches, win %, avg cups/match (from `PlayerStats` — `src/features/players/types.ts` L34). +- **Badges row:** the player's earned badges, compact (icon row, overflow "+N"). +- **Action:** one of `Claim` / `Claimed — waiting` (yours, with Withdraw) / `Accept · Decline` (they claimed you) / a subtle "pending with someone else" state. +- Phone: keep today's behavior for mainline (copy-to-clipboard affordance); regionals stay phone-less (`enrollFreeAgent` is already called with `""` phone via the regional path, and admin-enroll at L737 passes `""`). + +Fetch stats/badges in bulk — `getAllPlayerStats` (`src/features/players/server.ts` L174) and `getAllEarnedBadges` (`src/features/badges/server.ts`, keyed in `src/features/badges/queries.ts`) — filter client-side to the agents on the board. Do **not** issue per-agent `getPlayerStats`/`getPlayerBadges` calls (N+1). + +## The handshake +1. **Claim:** agent A taps Claim on agent B. A's `free_agents` row gets `claimed = `. A can have **one outgoing claim** at a time (withdraw to switch). +2. **B sees it** (SSE-refreshed): "A wants to partner with you" with Accept / Decline. B can hold multiple *incoming* claims; accepting one implicitly declines the rest. +3. **Accept →** server verifies A's claim still points at B, then creates the team (reusing the `confirmTeamAssignments` find-or-create logic), enrolls it in the tournament, and `unenrollFreeAgent`s both — both rows vanish from the pool, exactly like the admin flow. +4. **Decline →** clears A's `claimed` field. A is free to claim someone else. (Optionally notify A — see tunables.) +5. **Symmetry shortcut:** if B already has an outgoing claim on A when A claims B (or vice versa), that *is* mutual intent — auto-accept immediately, mirroring how `reportMatchScore` auto-finalizes on an identical cross-report. + +Team naming: default `" & "`, same shape the random generator produces; rename later via the existing team-edit flow. + +## Data model (decided: Option A — field on `free_agents`) +Two options were weighed: + +- **Option A (chosen): a self-relation on `free_agents`.** Add one nullable field `claimed` (relation → `free_agents`, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row's `claimed`; my incoming claims = rows where `claimed = `; decline = clear the field; match = both rows deleted. No status enum needed for v1 (one outgoing claim, terminal states delete rows). This mirrors the score-report precedent where pending state lives on the `matches` record, and unenroll/delete automatically cleans up outgoing claims (incoming pointers to a deleted row are cleared server-side on unenroll — one filtered update). +- **Option B (rejected for v1): a `partner_claims` collection** `{ tournament, from_agent, to_agent, status: pending|accepted|declined, created }`. Buys an audit trail, decline history (anti-re-spam), and native multi-claim — at the cost of a new collection, cross-record consistency, and cleanup hooks on unenroll. Revisit if tunables land on "multiple outgoing claims" or "declines block re-claiming". + +`getFreeAgents` grows to expand `claimed` and return `{ id, phone, player, claimedAgentId }`; the board derives the four card states from that plus "who points at me". + +## Server fns (new, in `src/features/tournaments/server.ts`) +All player-facing: `createServerFn().validator(zod).middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]).handler(...)` + `toServerResult`, resolving the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)` — copy `enrollFreeAgent` (L116) as the shell and the guard style of `confirmMatchScore`. + +- `proposePartner({ tournamentId, targetAgentId })` — caller must be an enrolled agent with no outgoing claim; target must be a different agent in the same tournament. Sets `claimed`. If target already claims caller → run the accept path (auto-match). Emits `tournament`; push to target. +- `withdrawPartnerClaim({ tournamentId })` — clears caller's `claimed`. Emits `tournament`. +- `respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean })` — verifies `fromAgent.claimed === callerAgent.id` (claim may have been withdrawn — fail with a friendly error). Decline: clear it. Accept: **reuse the team path** — extract the find-or-create-private-team + `enrollTeam` block from `confirmTeamAssignments` (L341–381) into a shared helper (e.g. `createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)`), call it, then `unenrollFreeAgent` both (which drops both rows and any pointers). Emit `team` + `tournament`; push to the claimer. + +Race safety: re-read both rows inside the handler before mutating (same optimistic pattern as score confirm — no locks, just verify-then-act; PB writes are serialized enough at this app's scale, per the existing flows). + +## Realtime + push +- **SSE:** reuse the existing `tournament` event (`src/lib/events/emitter.ts` L25). The handler in `src/hooks/use-server-events.ts` (L51) already invalidates the `['tournaments']` prefix, which covers `tournamentKeys.free_agents(id)` (`['tournaments','free_agents',id]`, `src/features/tournaments/queries.ts` L9) — **no new event type needed**; the board updates live as claims land. +- **Push:** `sendPushToPlayer` in `src/lib/push/index.ts` — its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (`PushPayload` in `src/lib/push/types.ts`): + - On claim → target: `{ title: "You've been claimed!", body: " wants to be your partner for ", url: "/", tag: "partner-claim-" }`. + - On accept → claimer: `{ title: "It's a match", body: " accepted — you're teammates", ... }`. + - Decline: **no push** (avoid rejection stings; the cleared state shows on the board). + +## Regional visibility gate (relaxed) +Delete the regional "you're enrolled, wait for assignment" dead-end in `enrolled-free-agent.tsx` (L45–74): regionals get the same board (stats, badges, claim) — that self-card + "Partners will be randomly assigned when enrollment closes" collapses into the board's header copy ("Claim a partner, or the admin pairs whoever's left"). Keep the regional enroll sheet copy in `enroll-free-agent.tsx` but update it to mention claiming. Phone stays hidden for regionals (the claim button replaces its purpose). + +## Admin fallback interplay +- Accepted handshakes remove both agents from `free_agents`, so `assign-partners.tsx` and `generateRandomTeams` see only the leftovers — **zero changes to the pairing math**. The even-count and ≥2 guards (L156–162) still apply to what remains. +- Pending (unaccepted) claims are invisible to the random pairer — it may split a pending pair. Acceptable for v1: random pairing runs at deadline, and `confirmTeamAssignments`'s `unenrollFreeAgent` sweep clears all claim state. Optional nicety (cheap, do it): `generateRandomTeams` seeds mutually-irrelevant pairs randomly but **keeps any pending claim pair together** when both are still in the pool — a one-pass pre-grouping before the shuffle. +- Admin's enrolled-players count and `assign-partners` header need no changes; they read the same `useFreeAgents` query. + +## Design system +- Mantine 8 + the app theme (`src/lib/mantine/mantine-provider.tsx`): `defaultRadius: "sm"`, dynamic `primaryColor`. Use `Card withBorder radius="md" p="md"` like the existing regional self-card so the board matches its neighbors. +- Accent discipline: **one** accented element per card — the `Claim` button. Waiting/pending states are `variant="subtle"`/`c="dimmed"`; Accept is filled, Decline is `variant="subtle" color="red"` (matching the enroll sheet's Cancel). +- Press feedback via the `flxn-press` theme; motion only where it earns it — a single 120–350ms iOS-eased transition when a card flips state (claimed → matched), no confetti, no bouncing. +- Badges row reuses the visual language of `src/features/badges/components/badge-showcase.tsx` at small size — don't invent a new badge chip. + +## Implementation notes for a fresh agent (build this cold) +Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/free-agents.md` (this file) fully first, then: +- **Migration:** one new `pb_migrations/*_updated_free_agents.js` adding the `claimed` self-relation (collectionId `pbc_2929550049`, maxSelect 1, required false, cascadeDelete false). Mimic `1758402128_updated_free_agents.js` (plain `migrate((app)=>{...})` with up + down). Players collection id is `pbc_3072146508`, tournaments `pbc_340646327`, teams `pbc_1568971955`. Collections have null rules; all access is server-side via `pbAdmin`. +- **Service layer:** extend `createTournamentsService` in `src/lib/pocketbase/services/tournaments.ts` — `setFreeAgentClaim(agentId, targetAgentId|null)`, `getFreeAgentByPlayer(playerId, tournamentId)` (the lookup already inlined in `unenrollFreeAgent` L198), and make `unenrollFreeAgent` also clear inbound pointers (`getFullList({ filter: 'claimed = ""' })` → clear each). Update `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` to surface `claimedAgentId`. +- **Server fns:** the three fns above in `src/features/tournaments/server.ts`, next to `enrollFreeAgent`. Extract `createOrReuseTeamForPair` from `confirmTeamAssignments` (L341–381) as a module-level helper and call it from both places — do not fork the team-creation logic. Emit `emitServerEvent({ type: "tournament", tournamentId })` (and `{ type: "team" }` on match) exactly like the existing fns; fire `sendPushToPlayer` (`src/lib/push/index.ts`) after the write, non-blocking (`.catch(log)`), never failing the request. +- **Client data:** hooks in `src/features/tournaments/hooks/` following `use-enroll-free-agent.ts` (uses `useServerMutation` from `src/lib/tanstack-query/hooks`, invalidates `tournamentKeys.free_agents(id)`). Board reads `useFreeAgents` + `getAllPlayerStats` / `getAllEarnedBadges` via new bulk queries keyed like `src/features/players/queries.ts` / `src/features/badges/queries.ts`. No new SSE event type: `use-server-events.ts`'s `tournament` handler already invalidates the `['tournaments']` prefix. +- **UI:** rework `enrolled-free-agent.tsx` into a `FreeAgentBoard` (new file under the same dir; keep `unenroll-free-agent.tsx` at the bottom). Card = `PlayerAvatar` + name + stats strip + badge row + action slot. Confirm-style incoming-claim UI: filled Accept, subtle red Decline, same layout rhythm as the score-confirm affordance in the match card. Sheets via `@/components/sheet/sheet` + `useSheet`, buttons via `@/components/button`. +- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only. "Claim sent", "You're teammates!", and a friendly error when a claim was withdrawn/taken ("They just partnered up — pick someone else"). +- **Copy updates:** `enroll-free-agent.tsx` sheet text (both branches) now mentions the board + claiming; delete the regional-only self-card branch in the pool view. +- **Design system:** Mantine 8 + the app theme; `defaultRadius: "sm"`; press feedback via `flxn-press`; one accent per card; match neighboring components, don't invent a new look. +- **Verify:** enroll two players as free agents (one regional tournament, one mainline), claim, accept — confirm a `private: true` team appears on the tournament, both agents leave the pool, and `assign-partners` shows only leftovers. Then run the admin random flow on an even leftover pool to prove the fallback is untouched. + +## Tunables / open questions +- **Multiple outgoing claims?** v1: one at a time (keeps Option A trivially consistent). If demand appears, move to Option B's collection. +- **Claim expiry?** v1: none — claims live until withdrawn, declined, or the pool is swept. Candidate: auto-expire via `updated` age (e.g. 24h) checked lazily in `getFreeAgents`. +- **Does admin auto-pair leftovers at a deadline?** Today it's manual (admin opens assign-partners and generates). Keep manual for v1; a scheduled auto-pair is a separate feature. +- **Decline memory:** should a declined claimer be blocked from re-claiming the same person? v1: no (rows carry no history). Option B enables it. +- **Keep pending pairs together in random pairing?** Recommended-cheap, but genuinely optional — cut it if it complicates the shuffle. +- **Phone visibility:** mainline keeps phones on the board for now; consider hiding until matched once claiming proves out. +- **Push copy/tag collapsing:** `tag: "partner-claim-"` collapses repeat claims from the same person; revisit if it feels spammy. diff --git a/docs/push-notifications.md b/docs/push-notifications.md new file mode 100644 index 0000000..38fa8a6 --- /dev/null +++ b/docs/push-notifications.md @@ -0,0 +1,89 @@ +# Push Notifications — Ideas Board (nothing decided) + +**Status:** brainstorm, NOT a spec. The push *foundation* is built; **zero product triggers are wired**. This doc is a menu of candidate notification types to pick from later — none of them are commitments, and the list is meant to be argued with, pruned, and added to. + +## Guiding principle (the one non-negotiable) + +**Minimal by default. Strictly opt-in. Do not annoy.** + +- Every notification type ships **OFF** unless the user opts in — and even opted-in volume stays low. +- Push is a scarce resource: each notification spends trust. One annoying ping and people nuke permission at the OS level and we never get it back. +- Only a **tiny high-value default-on set** for users who flip the master toggle. Current candidates: **"you're up next"** and **"you were claimed as a partner"** — both rare, both personally about *you*, both actionable right now. +- Prefer notifications that are: about the recipient personally > rare > time-sensitive > tappable to something useful. Anything failing two of those four probably shouldn't exist. + +## What already exists (the foundation) + +- **`src/lib/push/index.ts`** — `sendPushToPlayer(playerId, { title, body, url?, icon?, tag? })`. Sends to every device the player subscribed, auto-prunes dead endpoints (404/410). The file itself documents the trigger seams as **deliberately unwired**. +- **`push_subscriptions` collection** — `pb_migrations/1784200000_created_push_subscriptions.js`, service helpers in `src/lib/pocketbase/services/push.ts`. +- **Opt-in settings toggle** — `src/features/settings/components/notifications-section.tsx` (per-device enable + "send test notification", via `src/hooks/use-web-push.ts`). Today it's one master switch; per-type preferences are a follow-up idea (see bottom). +- **SSE domain events** — `src/lib/events/emitter.ts` already types `match | reaction | tournament | team | player | badge` events, emitted from real product code paths (`src/features/matches/server.ts`, `src/features/tournaments/server.ts`, `src/features/badges/server.ts`, `src/features/players/server.ts`, `src/features/teams/server.ts`). **Most push triggers can hang off the exact same emit sites** — the moment we `emitServerEvent(...)`, we already know something notification-worthy happened. + +Again: **no trigger below is wired.** The point of this board is to choose which few deserve to be. + +## The menu + +Columns: what fires it, who gets it, why it drives engagement, roughly how often it would fire, an annoyance-risk rating, and a suggested default (all of these are suggestions, not decisions). "Default ON" here means *on once the user enables notifications at all* — nothing fires for users who never flip the master switch. + +### BEFORE the tournament + +| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default | +|---|---|---|---|---|---|---| +| **New tournament posted / enrollment open** | Tournament created or opened for enrollment (`src/features/tournaments/server.ts` create emit, ~line 27) | All players | The starting gun — nobody wants to hear about a tournament secondhand | Rare (per tournament) | Low | Off (opt-in "announcements") | +| **Enrollment closing soon** | Timed job N hours before enrollment lock | Enrolled-nowhere players | FOMO nudge for fence-sitters | Once per tournament | **Med** — it's marketing, not information | Off | +| **You were claimed as a partner** | Free-agent partner assignment (`assignPartners` path in `src/features/tournaments/server.ts`, emits `team` + `tournament`, ~lines 385–397) | The claimed free agent(s) | Deeply personal — "someone picked YOU." Answers the question free agents are actively anxious about | Once per tournament per free agent | Low | **ON (candidate default)** | +| **Predictions are open** | Bracket generated / predictions window opens (`submitPrediction` lives in `src/features/predictions/server.ts`) | Enrolled players | Predictions only work if people fill them out before lock | Once per tournament | Low-med | Off | +| **Predictions closing soon** | Timed job before first-match lock (predictions lock on first match start) | Enrolled players who haven't submitted | Targeted (only non-submitters), genuinely useful deadline | ≤1 per tournament | Low-med | Off | +| **Side bets are open on a match** | Match hits `ready` (book opens per `docs/side-bets.md`) | Players with tokens, not in that match | Side-bets spec explicitly wants this nudge for marquee matches | Could be *every match* — needs throttling (e.g. finals only) | **High** if per-match; low if finals-only | Off | +| **Tournament starts in 1h** | Timed job vs. tournament start time | Enrolled players | Logistics — get people in the building | Once per tournament | Low | Off (but a strong opt-in) | + +### DURING the tournament + +| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default | +|---|---|---|---|---|---|---| +| **You're up next / your match started** | Admin starts the match — `startMatch` in `src/features/matches/server.ts` (~line 142, emits `match`) | The 2–4 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 3–6 per player per tournament | Low — this is the notification people *install the PWA for* | **ON (candidate default)** | +| **Score reported on your match — confirm needed** | `reportMatchScore` (~line 828) when the *other* team reports | The team that must confirm | Unblocks the bracket; the confirmer often doesn't know they're the bottleneck | ≤1 per match played | Low | Off, but arguably deserves default-on — it's a to-do, not a broadcast | +| **Your match result is final** | `finalizeMatch` (~line 753, single finalization path for admin `endMatch` + player `confirmMatchScore`) | Players in the match | Closure + "what's next" tap-through | 1 per match played | Low-med (you usually *know* — you were there) | Off | +| **You advanced / you were eliminated** | Bracket propagation after finalize (same `finalizeMatch` seam) | The advancing/eliminated team | "You're in the semis" is a brag-worthy moment; elimination push is a kindness debate | ≤1 per round | Med — elimination pushes can feel like salt | Off; if built, maybe advance-only | +| **Someone reacted to your match** | `toggleMatchReaction` (~line 964, emits `reaction`) | Players in the match | Social warm-fuzzies | Potentially **very** chatty | **High** — classic notification spam shape; needs heavy batching ("5 reactions on your match") if ever built | Off, probably forever | +| **Big upset just happened** | `finalizeMatch` + an upset heuristic (seed gap / prediction consensus) | Everyone at the tournament | Shared "did you SEE that" moment; drives people to the bracket | 0–2 per tournament if threshold is strict | Med — must be genuinely rare to land | Off | +| **Side-bet settled (you won/lost)** | Settlement inside `finalizeMatch` per `docs/side-bets.md` | Bettors on that match | Win reveals are dopamine; losses close the loop | Per bet placed (self-inflicted volume) | Low-med — user opted in by betting | Off (or implicit-on for people who bet?) | + +### AFTER the tournament + +| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default | +|---|---|---|---|---|---|---| +| **Final results / you placed** | Tournament completed (tournament status emit in `src/features/tournaments/server.ts`) | All enrolled; podium gets a personal variant ("You took 2nd") | The recap moment; personal placement > generic results | Once per tournament | Low | Off (single post-tournament push is defensible as default though) | +| **You earned a badge** | `src/features/badges/server.ts` award (~line 44, emits `badge` with `playerId`) | The recipient | Personal, rare, purely positive — near-ideal push shape | Rare | Low | Off, but a strong candidate to promote later | +| **Prediction results — how you scored** | Final match finalizes → predictions settle | Players who submitted a prediction | Only reaches opt-ins-by-behavior; leaderboard tap-through | Once per tournament | Low | Off | +| **Side-bets final standings** | Tournament completes → side-bets leaderboard freezes | Players who placed ≥1 bet | Same self-selected audience as above | Once per tournament | Low | Off | +| **Your FLXN Replay is ready** | *(Future feature — no code yet.)* Post-tournament recap generation | All enrolled | Personalized recap = the highest-retention artifact we could push | Once per tournament | Low | Off until the feature exists; then a strong default candidate | + +### AMBIENT / cross-tournament + +| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default | +|---|---|---|---|---|---|---| +| **A rival is in your next match** | *(Future feature — rivalries don't exist in code yet; head-to-head data does: `getMatchesBetweenPlayers` / `getMatchesBetweenTeams` in `src/features/matches/server.ts`)* | Both sides of the rivalry | "Revenge match" framing is the best narrative hook in the app | Rare | Low-med | Off until rivalries exist | +| **New tournament posted** | Tournament create emit (also listed under BEFORE — could be the same "announcements" type) | Everyone | Re-engages lapsed players between tournaments | Rare | Low | Off (opt-in "announcements") | +| **Free agents need partners** | N unclaimed free agents as enrollment deadline nears | Enrolled players without full teams | Matchmaking pressure in both directions | ≤1 per tournament | Med | Off | +| **You've been made admin / roster changes touch you** | `player` emit sites in `src/features/players/server.ts` | Affected player | Administrative courtesy | Very rare | Low | Off — probably not worth building | + +## Suggested tiny default-on set (opinion, not decision) + +If a user enables notifications at all, they get exactly two things until they say otherwise: + +1. **You're up next** — `startMatch` seam. +2. **You were claimed as a partner** — free-agent assignment seam. + +Everything else is opt-in per type. Both defaults are rare, personal, and actionable; neither can fire more than a handful of times per tournament. This matches the seams already name-dropped in the `sendPushToPlayer` doc comment. + +## How to wire one (when the picks are made) + +The pattern is deliberately boring: + +1. Find the emit site — e.g. `startMatch` in `src/features/matches/server.ts` already calls `emitServerEvent({ type: "match", ... })` when the admin starts a match. +2. Next to that emit, resolve the affected player IDs and call `sendPushToPlayer(playerId, { title, body, url })` from `src/lib/push/index.ts`, with `url` deep-linking to the match/tournament route. Fire-and-forget (don't block the server fn on push transport), use `tag` to collapse repeats of the same kind. +3. That's it — subscription storage, multi-device fan-out, and dead-endpoint pruning are already handled by the foundation. + +## Follow-up idea: per-type preferences + +Today `src/features/settings/components/notifications-section.tsx` is a single per-device switch. Once types are chosen, extend that section with **category toggles** (e.g. "My matches", "Announcements", "Social", "Results & recaps") persisted per *player* (not per device), and have each trigger check the player's category preference before calling `sendPushToPlayer`. Categories, not individual types — a settings screen with 15 switches is its own kind of annoying. diff --git a/src/features/tournaments/components/started-tournament/index.tsx b/src/features/tournaments/components/started-tournament/index.tsx index 892c012..3275889 100644 --- a/src/features/tournaments/components/started-tournament/index.tsx +++ b/src/features/tournaments/components/started-tournament/index.tsx @@ -108,7 +108,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({ {isTournamentOver && ( - + + )} 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 index 09f4888..028dfed 100644 --- a/src/features/tournaments/components/started-tournament/sections/nav-grid.module.css +++ b/src/features/tournaments/components/started-tournament/sections/nav-grid.module.css @@ -27,6 +27,11 @@ background-color: var(--mantine-color-default-hover); } +.tile:disabled { + opacity: 0.5; + cursor: default; +} + @media (prefers-reduced-motion: no-preference) { .tile { transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1); diff --git a/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx b/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx index cdd9e54..e7e85f4 100644 --- a/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx +++ b/src/features/tournaments/components/started-tournament/sections/nav-grid.tsx @@ -31,12 +31,13 @@ const RulesContent = lazy( interface NavTileProps { label: string; Icon: Icon; - onClick: () => void; + onClick?: () => void; + disabled?: boolean; } /** A single square-ish nav tile with icon-over-label and press feedback. */ -const NavTile = ({ label, Icon, onClick }: NavTileProps) => ( - +export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => ( + diff --git a/src/features/tournaments/components/tournament-stats.tsx b/src/features/tournaments/components/tournament-stats.tsx index e875ac4..5711d5d 100644 --- a/src/features/tournaments/components/tournament-stats.tsx +++ b/src/features/tournaments/components/tournament-stats.tsx @@ -175,25 +175,27 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => { )} {!tournament.regional && } - {hasGroupStage && ( + + {hasGroupStage && ( + + )} - )} - - {isTournamentPredictable(tournament) && ( - - )} + {isTournamentPredictable(tournament) && ( + + )} + {renderTeamStatsTable()} diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx index a0226dc..7db0ca7 100644 --- a/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx @@ -1,4 +1,3 @@ -import ListButton from "@/components/list-button"; import Sheet from "@/components/sheet/sheet"; import { useSheet } from "@/hooks/use-sheet"; import { UserListIcon } from "@phosphor-icons/react"; @@ -7,6 +6,7 @@ import { useFreeAgents } from "../../queries"; import { Text } from "@mantine/core"; import PlayerList from "@/features/players/components/player-list"; import { Player } from "@/features/players/types"; +import { NavTile } from "../started-tournament/sections/nav-grid"; interface EnrolledPlayersListButtonProps { tournamentId: string; @@ -24,11 +24,7 @@ const EnrolledPlayersListButton: React.FC = ({ t return ( <> - + {count === 0 ? ( diff --git a/src/features/tournaments/components/upcoming-tournament/header.tsx b/src/features/tournaments/components/upcoming-tournament/header.tsx deleted file mode 100644 index 295f970..0000000 --- a/src/features/tournaments/components/upcoming-tournament/header.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core"; -import { Tournament } from "../../types"; -import GlitchAvatar from "@/components/glitch-avatar"; -import { - CalendarIcon, - MapPinIcon, - TrophyIcon, - UsersIcon, -} from "@phosphor-icons/react"; -import { useMemo } from "react"; - -const Header = ({ tournament }: { tournament: Tournament }) => { - const tournamentStart = useMemo( - () => new Date(tournament.start_time), - [tournament.start_time] - ); - - return ( - - - - - - {tournament.location && ( - - - - - - {tournament.location} - - - )} - - - - - - - {tournamentStart.toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - })}{" "} - at{" "} - {tournamentStart.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} - - - - - ); -}; - -export default Header; diff --git a/src/features/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx index 8923bd0..b65fb82 100644 --- a/src/features/tournaments/components/upcoming-tournament/index.tsx +++ b/src/features/tournaments/components/upcoming-tournament/index.tsx @@ -1,15 +1,17 @@ import { Suspense, useMemo } from "react"; import { Tournament } from "../../types"; import { useAuth } from "@/contexts/auth-context"; -import { Box, Card, Divider, Group, Stack, Text, Title } from "@mantine/core"; +import { Box, Divider, Paper, SimpleGrid, Stack, Text } from "@mantine/core"; import Countdown from "@/components/countdown"; -import ListLink from "@/components/list-link"; import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react"; +import { useNavigate } from "@tanstack/react-router"; import EnrollTeam from "./enroll-team"; import EnrollFreeAgent from "./enroll-free-agent"; import TeamListButton from "./team-list-button"; import EnrolledPlayersListButton from "./enrolled-players-list-button"; -import Header from "./header"; +import Header from "../started-tournament/header"; +import SectionHeading from "../started-tournament/sections/section-heading"; +import { NavTile } from "../started-tournament/sections/nav-grid"; import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton"; import TeamCard from "@/features/teams/components/team-card"; import RegionalTeamCard from "@/features/teams/components/regional-team-card"; @@ -24,6 +26,7 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ tournament, }) => { const { user, roles } = useAuth(); + const navigate = useNavigate(); const isAdmin = useMemo(() => roles.includes("Admin"), [roles]); const userTeam = useMemo( @@ -52,121 +55,109 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
- - {tournament.desc && {tournament.desc}} + {tournament.desc && ( + + {tournament.desc} + + )} - - - - - - Enrollment - - {isEnrollmentOpen && ( - - - + + + ) : undefined + } + /> + + + + {!isUserEnrolled && !isEnrollmentOpen && ( + + Enrollment has been closed for this tournament. + )} - - {!isUserEnrolled && !isEnrollmentOpen && ( - - Enrollment has been closed for this tournament. - - )} - - {!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && ( - <> - {!tournament.regional && ( - <> - - - - )} - - - )} - - {isUserEnrolled && ( - <> - }> - {tournament.regional === true ? ( - - ) : ( - - )} - - {tournament.regional !== true && ( - <> - - {isEnrollmentOpen && ( - + {!tournament.regional && ( + <> + - )} - - )} - - )} + + + )} + + + )} - { - isFreeAgent && isEnrollmentOpen && ( + {isUserEnrolled && ( + <> + }> + {tournament.regional === true ? ( + + ) : ( + + )} + + {tournament.regional !== true && ( + <> + + {isEnrollmentOpen && ( + + )} + + )} + + )} + + {isFreeAgent && isEnrollmentOpen && ( - ) - } - - - - + )} + + + + - - {isAdmin && ( - - )} - - {tournament.regional === true ? ( - (tournament.teams && tournament.teams.length > 0) ? ( - - ) : ( - - ) - ) : ( - - )} - + + + + {isAdmin && ( + + navigate({ to: `/admin/tournaments/${tournament.id}` }) + } + /> + )} + + {tournament.regional === true ? ( + (tournament.teams && tournament.teams.length > 0) ? ( + + ) : ( + + ) + ) : ( + + )} + + + ); diff --git a/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx index 958f55a..258e6d1 100644 --- a/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx +++ b/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx @@ -1,10 +1,10 @@ -import ListButton from "@/components/list-button" import Sheet from "@/components/sheet/sheet" import { useSheet } from "@/hooks/use-sheet" import { ListIcon } from "@phosphor-icons/react" import { useTournament } from "../../queries" import { Button, Flex, Loader, Stack } from "@mantine/core" import { lazy, Suspense } from "react" +import { NavTile } from "../started-tournament/sections/nav-grid" const RulesContent = lazy(() => import("./rules-content")); @@ -18,11 +18,7 @@ const RulesListButton: React.FC = ({ tournamentId }) => { return ( <> - + diff --git a/src/features/tournaments/components/upcoming-tournament/skeleton.tsx b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx index 6ecf3b9..8177ba6 100644 --- a/src/features/tournaments/components/upcoming-tournament/skeleton.tsx +++ b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx @@ -1,44 +1,38 @@ -import { Box, Card, Divider, Flex, Group, Skeleton, Stack } from "@mantine/core"; +import { Box, Divider, Group, Paper, SimpleGrid, Skeleton, Stack } from "@mantine/core"; const UpcomingTournamentSkeleton = () => { return ( - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + + ); }; -export default UpcomingTournamentSkeleton; \ No newline at end of file +export default UpcomingTournamentSkeleton; diff --git a/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx index 9b97fc8..177717d 100644 --- a/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx +++ b/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx @@ -1,10 +1,10 @@ -import ListButton from "@/components/list-button" import Sheet from "@/components/sheet/sheet" import TeamList from "@/features/teams/components/team-list" import { TeamInfo } from "@/features/teams/types" import { useSheet } from "@/hooks/use-sheet" import { UsersIcon } from "@phosphor-icons/react" import { useMemo } from "react" +import { NavTile } from "../started-tournament/sections/nav-grid" interface TeamListButtonProps { teams: TeamInfo[] @@ -16,11 +16,7 @@ const TeamListButton: React.FC = ({ teams, isRegional }) => const { open, isOpen, toggle } = useSheet(); return ( <> - + @@ -29,4 +25,4 @@ const TeamListButton: React.FC = ({ teams, isRegional }) => ) } -export default TeamListButton; \ No newline at end of file +export default TeamListButton;