79 lines
11 KiB
Markdown
79 lines
11 KiB
Markdown
# 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 = "<id>"`.
|
||
- **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 && <ListLink label="View Replay" to={\`/tournaments/${tournament.id}/replay\`} Icon={...} />}` 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.
|