Opponent Team Profiles

Shows every persisted opponent scouting profile as a roster — overall and Aff/Neg side record, a "notably stronger" side badge, and the team's most commonly run argument tags and cases — ordered by rounds recorded (most scouted opponent first).

  • Route: /opponents
  • Nav: the Tools page's Prep & Practice group; the Reason Editor's Workspace menu (t opponent in Ctrl/Cmd-Shift-Space's command palette)
  • Package: debate-round

What it shows

For every opposing team with a saved OpponentTeamProfile:

ColumnMeaning
TeamteamId
RoundsTotal rounds recorded against this team, across every tournament tracked
RecordOverall win-loss record and win rate
Side recordAff/Neg win record, flagged "stronger on aff/neg" once it clears the opponent-team-profile.ts threshold
Common argumentsUp to 3 most-frequent argumentTags, most frequent first
Common casesUp to 3 most-frequent caseNames, most frequent first

Logging a scouted round

The Log a scouted round form above the roster is the in-app way to create a profile. One round at a time is recorded for an opposing team:

FieldNotes
Team IDRequired. The opposing team being scouted — this is what the round is aggregated under
TournamentRequired. Distinct names drive the profile's tournamentsAttended count
DateRequired
DivisionRequired
Side they debatedAff or Neg — drives the profile's side split and its "notably stronger" flag
They won this roundOff by default; drives the overall and per-side win record
Case runOptional. Feeds Common cases
Argument tagsOptional, comma-separated. Feeds Common arguments
Debated againstOptional. The other team's ID, kept for getHeadToHeadRecords lookups

Submitting appends the round to the persisted round history and re-derives that team's whole profile from its full logged history, so every roster column stays a derived value — there is deliberately no direct editing of an aggregate field. After a successful save the form keeps the team ID and division (the fields most likely to repeat across a tournament) and clears the rest.

Correcting a logged round

The Logged rounds table below the roster lists every round recorded so far, filtered to one team by typing into Filter by team ID (a case-insensitive substring match on the team id, so a long history doesn't bury the team you just logged).

Each row has up to four actions:

  • Edit loads the round back into the form above, which switches to "Edit logged round" with Save changes / Cancel buttons. Saving rewrites that round in place (keeping its id and its position in the history) and re-aggregates the team from the updated history. Changing the Team ID while editing reassigns the round to a different team and re-aggregates both — dropping the previous team's derived profile if that was its last round.
  • Delete removes the round and re-aggregates the affected team from whatever rounds remain, deleting the derived profile entirely once its last round is gone (rather than leaving a zero-round one). Deleting the round currently being edited also cancels the edit.
  • Undo last edit appears only on a round that has at least one edit still undoable, and steps it back to the version it held immediately before its most recent edit — re-aggregating the affected team (or teams, if that edit reassigned the round) the same way an Edit/Save would. Clicking it repeatedly walks further back through up to the last 10 corrections, one edit at a time; deleting the round discards its undo history along with it.
  • Redo appears only on a round that has at least one undone edit still redoable — i.e. right after clicking Undo — and steps it forward again to the version Undo just replaced, the same way Undo itself re-aggregates. Clicking it repeatedly walks forward through however many times Undo was just clicked in a row. Making a fresh Edit/Save after an Undo discards the redo history (the same way any undo/redo stack invalidates "the future" once a new edit branches off from it), and deleting the round discards it too.

Bulk CSV import

The Bulk import (CSV) section below the "Log a scouted round" form imports many rounds at once instead of typing them in one at a time. Paste a CSV with a header row naming the columns — any order, matched case-insensitively — then one row per round:

ColumnRequiredNotes
teamIdYes
tournamentNameYes
dateYes
divisionYes
sideYesaff or neg (case-insensitive)
wonYestrue/false, yes/no, 1/0, or win/loss (case-insensitive)
argumentTagsNoSemicolon-separated (e.g. kritik;topicality) — CSV's own comma is the column delimiter, so a comma can't also separate tags within one cell
caseNameNo
opponentTeamIdNo

Quoted fields ("Westlake, AB") may contain commas and escaped quotes (""), but not a literal newline. A row missing a required field, or with an unrecognized side/won value, is skipped and reported rather than aborting the whole import; the import summary names the imported and skipped counts and the first skipped row's reason. Every well-formed row is appended to the same round history the manual form writes to, and each affected team's profile is re-aggregated once the whole batch is in (not once per row) — a bulk import behaves exactly like logging each round by hand, just faster.

Downloading a scouting report

A Download report button in the panel header exports the whole roster — every persisted profile, in the same rounds-recorded-descending order shown on screen — as a plain-text file via rankings/opponent-team-profile.ts#buildOpponentScoutingReportText, one buildOpponentScoutingSummary block per team separated by a blank line, so it reads the same whether it's viewed on screen or shared as a opponent-scouting-report.txt download ahead of a tournament. Mirrors Research Progress Tracking's "Download report" button and its anchor+Blob download pattern exactly.

Comparing us vs. an opponent

The Compare vs. opponent section (shown once at least one opponent profile is on the roster) closes the "a side-by-side us-vs-opponent comparison view" follow-up named under the "🕵️ Opponent Team Profiles" bullet in TODO.md. Pick an opponent from the dropdown and click Compare to build a two-column view:

  • Us is computed on the fly from debate-round's state/ownRoundHistory.ts#listOwnRoundHistory() — the same round-history log the Pre-Round Briefings panel's "Prior meetings" section already reads — aggregated through the same buildOpponentTeamProfile("self", ...) used for every opponent, so no new scouting math exists for "our" side.
  • The chosen opponent is its already-persisted OpponentTeamProfile from the roster.

rankings/opponent-team-profile.ts#buildOpponentTeamComparison(a, b) builds the comparison: rounds recorded, overall record, and Aff/Neg side record are rendered side by side directly from each profile, and argument tags are split three ways — tags both teams have run (Shared arguments, ranked by combined frequency across both), tags only we've run (Us-only arguments), and tags only the opponent has run (<opponent>-only arguments) — the scouting-useful signal of what a team runs that the other doesn't. A Download comparison button exports the built comparison as a plain-text file via buildOpponentTeamComparisonText/ opponentTeamComparisonFilename, mirroring AI Coach Mode's "Compare two sessions" section and its own download button.

If we have no logged round history yet, "Us" simply renders as a zero-round profile ("no recorded rounds") rather than erroring — log rounds through the Pre-Round Briefings panel's own round-logging form to populate it.

Data flow

state/opponentRoundRecords.ts (localStorage: opponentRoundRecords, in debate-data-sync)
  → recordOpponentRound(entry)                      — appends one OpponentRoundRecordEntry,
                                                       then re-aggregates that team via the
                                                       existing buildOpponentTeamProfile and
                                                       persists through saveOpponentTeamProfile
  → updateOpponentRoundRecord(entry)                — replaces one round by id, in place, saving
                                                       its pre-edit version to a small per-round
                                                       undo history first, then re-aggregates its
                                                       team (and the previous team too, when the
                                                       round is reassigned)
  → undoLastOpponentRoundRecordEdit(id)              — restores a round to the version held
                                                       before its most recent edit, popping that
                                                       version off the id's undo history, then
                                                       re-aggregates the same way
                                                       updateOpponentRoundRecord would
  → hasOpponentRoundRecordEditHistory(id)            — whether a round has at least one edit
                                                       still undoable
  → listOpponentRoundRecordEditHistory(id)           — a round's prior versions,
                                                       most-recent-edit-first
  → redoLastOpponentRoundRecordEdit(id)              — re-applies the version replaced by the
                                                       most recent undo, popping that version off
                                                       the id's redo history and pushing the
                                                       version it replaces back onto the undo
                                                       history, then re-aggregates the same way
                                                       undoLastOpponentRoundRecordEdit would
  → hasOpponentRoundRecordRedoHistory(id)            — whether a round has at least one undone
                                                       edit still redoable
  → listOpponentRoundRecordRedoHistory(id)           — a round's undone versions,
                                                       most-recently-undone-first
  → deleteOpponentRoundRecord(id)                   — removes one round and its undo/redo
                                                       history, then re-aggregates (deleting the
                                                       profile if none remain)
  → bulkImportOpponentRoundRecords(rawCsv)          — parses a pasted CSV via
                                                       rankings/opponent-round-csv-import.ts's
                                                       parseOpponentRoundRecordsCsv, appends every
                                                       well-formed row, then re-aggregates each
                                                       affected team once for the whole batch
  → rebuildOpponentTeamProfileFromRecords(teamId)   — re-aggregation alone

rankings/opponent-round-csv-import.ts (pure, no storage, in debate-data-sync)
  → parseOpponentRoundRecordsCsv(rawCsv)            — header-driven CSV parser producing
                                                       OpponentRoundRecords, skipping and
                                                       reporting malformed rows

state/opponentTeamProfiles.ts (localStorage: opponentTeamProfiles, in debate-data-sync)
  → buildOpponentTeamProfilesRoster()              — lists every persisted OpponentTeamProfile,
                                                       ordered by rounds recorded descending
                                                       (ties broken alphabetically)
  → panels/OpponentTeamProfilesPanel.tsx            — renders the "Log a scouted round" form,
                                                       the "Bulk import (CSV)" section, the
                                                       scouting roster table, and the
                                                       logged-rounds list (in debate-round)
  → apps/debate-ai.com/app/opponents/page.tsx       — mounts the panel as a route

The two stores are deliberately split: opponentTeamProfiles holds only the aggregate, so opponentRoundRecords persists the raw rounds behind it, keyed by a per-round id (a team plays many rounds) the way debate-speech-writer's judgeRoundRecords.ts and this package's tournamentResults.ts do. debate-round's state/ownRoundHistory.ts stores the same record type from this team's own perspective for pre-round briefings and stays separate — a team's own rounds shouldn't surface as an opponent's scouting profile. A third store, opponentRoundRecordEditHistory (keyed by round id, capped at the 10 most recent prior versions per round), holds what each round looked like before each edit, so a correction can be undone via undoLastOpponentRoundRecordEdit instead of being permanent. A fourth store, opponentRoundRecordRedoHistory (same per-round cap), holds whatever version undoLastOpponentRoundRecordEdit most recently replaced, so redoLastOpponentRoundRecordEdit can step forward through it again; a fresh edit or a delete discards it, the same way it discards the undo history — mirroring debate-speech-writer's judgeRoundRecords.ts undo/redo stacks exactly.

Every profile field already existed and was Vitest-covered by rankings/opponent-team-profile.ts's buildOpponentTeamProfile; this feature closes follow-up (b), "a scouting-card/panel UI," named under the "🕵️ Opponent Team Profiles" bullet in TODO.md, adding one small ordering helper (buildOpponentTeamProfilesRoster) to state/opponentTeamProfiles.ts rather than introducing new scouting logic. Vitest-covered in packages/debate-data-sync/test/opponentTeamProfiles.test.ts and packages/debate-data-sync/test/opponentRoundRecords.test.ts, which also Vitest-covers the undo/redo edit history described above. The bulk CSV import closes follow-up (c), "a bulk CSV import for scouted rounds," from the same bullet — its pure parser is Vitest-covered in packages/debate-data-sync/test/opponent-round-csv-import.test.ts, and the persistence composition (bulkImportOpponentRoundRecords) in opponentRoundRecords.test.ts alongside the rest of that store.

The "Filter by team ID" input on the logged-rounds list now backs onto a <datalist> of listOpponentTeamIds() — every distinct team id with at least one logged round, sorted alphabetically — so typing offers the ids actually on record instead of a blank guess. When the typed filter matches nothing, findNearestOpponentTeamId(query) (a small case-insensitive Levenshtein edit-distance search over the same id list, local to state/opponentRoundRecords.ts, mirroring debate-speech-writer's findNearestJudgeId) suggests the closest known id as a clickable "Did you mean <id>?" prompt that refills the filter. Both are Vitest-covered in opponentRoundRecords.test.ts.

Cross-tab live update

Until now, OpponentTeamProfilesPanel only read its persisted roster and logged-round history on mount, or right after its own log/edit/undo/redo/ delete/bulk-import actions — a teammate's second open tab (or a second browser window on the same machine) logging a scouted round for the same team showed a stale roster until it re-rendered for some unrelated reason. The browser's storage event fires only in other same-origin tabs/ windows, never the one that made the write, so it's exactly the missing cross-tab signal every other closed panel in this repo already uses (see shared-flow-sync.md's "Cross-tab live update" section).

A new pure helper, debate-round's flow/live-update.ts's isOpponentTeamProfilesPanelLiveUpdateStorageEvent, checks whether the event's key is one of this panel's four backing stores (opponentTeamProfiles, the aggregated roster; opponentRoundRecords, the logged-round history; and opponentRoundRecordEditHistory/ opponentRoundRecordRedoHistory, which decide whether a round shows an Undo/Redo action) or null (a localStorage.clear()) — deliberately excluding ownRoundHistory, which the panel only reads inside the on-demand "Compare vs. opponent" action, not on refresh. OpponentTeamProfilesPanel subscribes to window's storage event and calls its existing refresh() closure when the predicate matches, re-deriving the roster and logged-round list the same way its own actions already do — the in-progress "Log a scouted round" form draft, the "Bulk import (CSV)" textarea, and any built "Compare vs. opponent" comparison are left untouched, only the persisted roster/history re-reads.

Vitest-covered in debate-round's test/live-update.test.ts (every backing-store key, the null-key clear-all case, the excluded ownRoundHistory key, and unrelated/substring-matching keys staying ignored).

Known gaps

  • No real round-history data source yet (follow-up (a) — no Tabroom/tab-service pairing or ballot sync produces OpponentRoundRecords in this repo today); every round is entered by hand through this panel's form (one at a time or via the Bulk import (CSV) section), or supplied by a caller of recordOpponentRound/bulkImportOpponentRoundRecords/saveOpponentTeamProfile directly. This is the same gap the Judge Profiles panel has.
  • Editing a round is all-or-nothing per round: there is no history of what a round looked like before an edit, so a correction can't be undone. Closed: Undo last edit / Redo actions now step a round back to (and forward from) any of its last 10 prior versions (see "Correcting a logged round" above), mirroring the same fix already shipped for Judge Profiles.
  • Profiles are per-browser localStorage Closed for a signed-in user: opponentTeamProfiles and the opponentRoundRecords they aggregate now sync to the account through saved_tool_records — see Tool Data Sync. They are still per-user rather than a shared team resource, and there are still no identity/permission checks on who may log a round against a team beyond "it's your own account's data".