11 KiB
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 exactisCompletederivation fromtransformTournament(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 atsrc/lib/pocketbase/util/transform-types.ts:30).pbAdmin.getTournament(id)already returns matches + teams expanded, plusteam_statsfrom theteam_stats_per_tournamentview (src/lib/pocketbase/services/tournaments.ts:21). - Stats views —
team_stats_per_tournamentgives per-team W/L, cups for/against, margins for this event (mapped ontotournament.team_statsintransformTournament). Globalplayer_stats/player_mainline_stats/player_regional_statsviews 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 —
reactionscollection{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 filtermatch.tournament = "<id>". - Predictions —
computePredictionScore(points, correct,predictedChampionId) insrc/features/predictions/utils.tsandgetPredictionsLeaderboardinsrc/features/predictions/server.tsgive points / rank / called-the-champ directly. - Badges —
badge_progress(earnedboolean, 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_againstfromteam_stats. - Biggest blowout margin — max
|home_cups - away_cups|across their wins, with opponent. - Longest win streak — sort their matches by
order(fallbackend_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_placederivation. - Biggest upset — among ended matches where both
home_seedandaway_seedare 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 — maxot_count(tie-break:end_time - start_timewhen both set). - Most cups in one game (single team) — max of
home_cups/away_cupswith team + match. - Totals — Σ cups, count of ended matches, count with
ot_count >= 1(+ total OT periods). - MVP — best
team_statsrow 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 withListLinkrows ("View Bracket", "View Predictions" ~lines 178-196). Add "View Replay" there, gated on the sameisCompletememo 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 overMatch[]/TournamentTeamStats[]inutils.ts, unit-testable likesrc/features/predictions/utils.ts. - Server fns: two —
getTournamentRecap(tournamentId)andgetPlayerRecap({tournamentId, playerId})— followingcreateServerFn().validator(zod).middleware([...]).handler(...)+toServerResult, with.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]); copy the shape ofgetPredictionsLeaderboardinsrc/features/predictions/server.ts(it already does getTournamentOrThrow + derive-from-matches). Resolve the caller viapbAdmin.getPlayerByAuthId(context.userAuthId). Throw if the tournament isn't complete (reuse theisCompletelogic; export it from a shared util rather than copy-pasting a third time — it already exists twice intransform-types.ts). - Reactions per tournament: add
getReactionsForTournament(tournamentId)tosrc/lib/pocketbase/services/reactions.ts—getFullList({ filter: \match.tournament="${id}"` })— and aggregate counts per match inutils.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_progresswhereearned = trueandupdatedfalls 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) andsrc/app/routes/_authed/tournaments/$id.replay_.$playerId.tsx(player), mirroring$id.predictions.tsx/$id.predictions_.$playerId.tsxexactly (samecreateFileRoute+ loader header + Suspense pattern; note sub-routes use$id, only the profile route uses$tournamentId). - Client data:
useServerSuspenseQuerywith areplayKeys/replayQueriesmodule copyingsrc/features/predictions/queries.ts. Data is immutable post-completion — no SSE wiring, no invalidation, longstaleTimeis 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 fortournament.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 theflxn-pressactiveClassName). 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. ReuseTeamAvatar/PlayerAvatarin 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.earnedis 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 lackhome_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.