# 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.