Files
flxn-app/docs/flxn-replay.md
T
yohlo 22c282d8fa
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m49s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 30s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 9m40s
more for the overhaul
2026-07-22 16:41:44 -07:00

11 KiB
Raw Blame History

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)

  • Matchesstatus: "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 viewsteam_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).
  • Reactionsreactions 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>".
  • PredictionscomputePredictionScore (points, correct, predictedChampionId) in src/features/predictions/utils.ts and getPredictionsLeaderboard in src/features/predictions/server.ts give points / rank / called-the-champ directly.
  • Badgesbadge_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):

  • WL + win % — team's rows from tournament.team_stats, or count from matches.
  • Cups sunk / concededtotal_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 41 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 / attendancetournament.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.tsgetFullList({ 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_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 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 the flxn-press activeClassName). The recap should feel like a highlight reel, not a table — but tasteful motion only (iOS-like easing, 120350ms), 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.