diff --git a/custom_components/quizify/analytics.py b/custom_components/quizify/analytics.py
index faf6fc3..2a856ac 100644
--- a/custom_components/quizify/analytics.py
+++ b/custom_components/quizify/analytics.py
@@ -63,6 +63,16 @@ class PlayerStanding(TypedDict):
total_score: int
+class HeadToHead(TypedDict):
+ """The duel between two returning players, for the TV lobby (#613)."""
+
+ left: str
+ right: str
+ left_wins: int
+ right_wins: int
+ games: int
+
+
class EveningTally(TypedDict):
"""Tonight's running score across several games (#612)."""
@@ -388,6 +398,54 @@ async def _prune_old_records(self) -> None:
len(games),
)
+ def get_head_to_head(self, present: list[str]) -> HeadToHead | None:
+ """The duel between the two present players who have met most often.
+
+ "Met" means both appeared in the same recorded game; the winner of that
+ meeting is whoever scored higher, which is not the same as the game's
+ overall winner — a duel is between these two, not against the room.
+
+ Scope is the detailed game history, which prunes at RETENTION_DAYS /
+ MAX_DETAILED_RECORDS. So this is honestly "recent", not all-time, and
+ the caller labels it accordingly. A pairwise rollup in the unpruned map
+ would extend it, at a cost that grows quadratically with players — not
+ worth it for a lobby line.
+
+ Returns ``None`` unless a pair has met at least twice: a single shared
+ game makes a "1–0" that reads like a record and is a coincidence.
+ """
+ names = [n for n in dict.fromkeys(present) if n]
+ if len(names) < 2:
+ return None
+
+ best: HeadToHead | None = None
+ for i, left in enumerate(names):
+ for right in names[i + 1 :]:
+ left_wins = right_wins = met = 0
+ for game in self._data.get("games", []):
+ scores = game.get("player_scores") or {}
+ if left not in scores or right not in scores:
+ continue
+ met += 1
+ if scores[left] > scores[right]:
+ left_wins += 1
+ elif scores[right] > scores[left]:
+ right_wins += 1
+ # A draw counts as a meeting and goes to nobody.
+ if met < 2:
+ continue
+ # Most-met pair wins; ties fall to the alphabetically first
+ # pair so the TV does not flicker between equals on rejoin.
+ if best is None or met > best["games"]:
+ best = {
+ "left": left,
+ "right": right,
+ "left_wins": left_wins,
+ "right_wins": right_wins,
+ "games": met,
+ }
+ return best
+
# More than this between two games and they belong to different evenings
# (#612). A calendar day was the obvious alternative and is worse: it cuts
# every party that runs past midnight, which is exactly the party this line
diff --git a/custom_components/quizify/server/websocket.py b/custom_components/quizify/server/websocket.py
index 1b019fe..db69e90 100644
--- a/custom_components/quizify/server/websocket.py
+++ b/custom_components/quizify/server/websocket.py
@@ -1493,6 +1493,7 @@ async def _flush_roster_after_window(self) -> None:
# too, or the lobby keeps showing a team nobody is in.
"teams": gs.team_registry.to_list(),
})
+ await self._send_head_to_head(gs)
# A roster change that landed DURING the broadcast set _roster_dirty
# again; _ensure_roster_flush won't start a new task while this one is
@@ -1550,6 +1551,33 @@ def _cancel_progress_flush(self) -> None:
self._progress_flush_task = None
self._progress_dirty = False
+ async def _send_head_to_head(self, game_state: QuizifyGameState) -> None:
+ """Show the TV the duel between the two present regulars (#613).
+
+ Lobby only: a rivalry line belongs before the game, and mid-game it
+ would compete with the question for the same screen.
+
+ To the TV and admin, never to the phones — this is a deliberate
+ reversal of #371, which sends each player only their OWN standing.
+ Putting two people's record in front of the room is a different call,
+ made knowingly, and the phones stay out of it.
+ """
+ if game_state.phase != GamePhase.LOBBY:
+ return
+ analytics = game_state.stats_service
+ if analytics is None:
+ return
+ duel = analytics.get_head_to_head(
+ [p.name for p in game_state.get_players()]
+ )
+ if duel is None:
+ # Fewer than two present, or no pair has met twice. Silence beats a
+ # "1-0" that reads like a record and is a coincidence.
+ return
+ await self._conn.broadcast_to_admins_and_dashboards(
+ {"type": "head_to_head", **duel}
+ )
+
def _cancel_roster_flush(self) -> None:
"""Cancel any pending roster-flush task (called on cleanup)."""
if self._roster_flush_task is not None:
diff --git a/custom_components/quizify/www/dashboard.html b/custom_components/quizify/www/dashboard.html
index 058df10..3417e6b 100644
--- a/custom_components/quizify/www/dashboard.html
+++ b/custom_components/quizify/www/dashboard.html
@@ -154,6 +154,29 @@
margin-top: 4px;
}
+ .dashboard-h2h {
+ margin-top: 14px;
+ text-align: center;
+ font-size: clamp(1rem, 1.7vw, 1.35rem);
+ color: var(--dash-text-white);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .dashboard-h2h .h2h-label {
+ display: block;
+ font-size: 0.72em;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--dash-text-muted);
+ margin-bottom: 3px;
+ }
+
+ .dashboard-h2h .h2h-scope {
+ font-size: 0.68em;
+ color: var(--dash-text-muted);
+ margin-left: 8px;
+ }
+
.dashboard-evening-tally {
font-size: clamp(1rem, 1.7vw, 1.4rem);
color: var(--dash-text-white);
@@ -1344,6 +1367,11 @@
+
+
@@ -1502,6 +1530,7 @@
timerFill: document.getElementById('timer-fill'),
answerProgress: document.getElementById('answer-progress'),
eveningTally: document.getElementById('evening-tally'),
+ lobbyH2h: document.getElementById('lobby-h2h'),
questionCategory: document.getElementById('question-category'),
questionImage: document.getElementById('question-image'),
questionMedia: document.getElementById('question-media'),
@@ -1793,6 +1822,9 @@
case 'evening_tally':
handleEveningTally(msg);
break;
+ case 'head_to_head':
+ handleHeadToHead(msg);
+ break;
case 'finale':
case 'game_ended':
handleFinale(msg);
@@ -2114,6 +2146,25 @@
}).join('');
}
+ function handleHeadToHead(msg) {
+ if (!els.lobbyH2h) return;
+ if (!msg || !msg.left || !msg.right) {
+ els.lobbyH2h.classList.add('hidden');
+ return;
+ }
+ var t = (window.QuizifyI18n && window.QuizifyI18n.t)
+ || function (k) { return k; };
+ // "last 90 days" is stated, not implied: the detailed history
+ // prunes at RETENTION_DAYS, so calling this an all-time record
+ // would be a claim the data cannot support.
+ els.lobbyH2h.innerHTML =
+ '' + escapeHtml(t('dashboard.h2hLabel')) + ''
+ + escapeHtml(msg.left) + ' ' + msg.left_wins
+ + ' – ' + msg.right_wins + ' ' + escapeHtml(msg.right)
+ + '' + escapeHtml(t('dashboard.h2hRecent')) + '';
+ els.lobbyH2h.classList.remove('hidden');
+ }
+
function handleEveningTally(msg) {
if (!els.eveningTally) return;
var leaders = (msg && msg.leaders) || [];
diff --git a/custom_components/quizify/www/i18n/de.json b/custom_components/quizify/www/i18n/de.json
index 3fb0b39..41e50f0 100644
--- a/custom_components/quizify/www/i18n/de.json
+++ b/custom_components/quizify/www/i18n/de.json
@@ -554,6 +554,8 @@
"runnersUp": "Außerdem dabei",
"leaderboardMore": "+{count} weitere",
"tonightLabel": "Heute Abend",
+ "h2hLabel": "Duell",
+ "h2hRecent": "letzte 90 Tage",
"tonightGames": "{games} Spiele",
"tonightWins": "{wins} Siege",
"tonightWinsOne": "1 Sieg"
diff --git a/custom_components/quizify/www/i18n/en.json b/custom_components/quizify/www/i18n/en.json
index 5749927..6c1aafb 100644
--- a/custom_components/quizify/www/i18n/en.json
+++ b/custom_components/quizify/www/i18n/en.json
@@ -554,6 +554,8 @@
"runnersUp": "Also playing",
"leaderboardMore": "+{count} more",
"tonightLabel": "Tonight",
+ "h2hLabel": "Head to head",
+ "h2hRecent": "last 90 days",
"tonightGames": "{games} games",
"tonightWins": "{wins} wins",
"tonightWinsOne": "1 win"
diff --git a/custom_components/quizify/www/i18n/es.json b/custom_components/quizify/www/i18n/es.json
index 0c22fb9..500979d 100644
--- a/custom_components/quizify/www/i18n/es.json
+++ b/custom_components/quizify/www/i18n/es.json
@@ -554,6 +554,8 @@
"runnersUp": "También juegan",
"leaderboardMore": "+{count} más",
"tonightLabel": "Esta noche",
+ "h2hLabel": "Duelo",
+ "h2hRecent": "últimos 90 días",
"tonightGames": "{games} partidas",
"tonightWins": "{wins} victorias",
"tonightWinsOne": "1 victoria"
diff --git a/tests/test_head_to_head_613.py b/tests/test_head_to_head_613.py
new file mode 100644
index 0000000..27b8580
--- /dev/null
+++ b/tests/test_head_to_head_613.py
@@ -0,0 +1,161 @@
+"""The duel between two returning players, on the TV lobby (issue #613).
+
+The code asked for this itself: `get_player_standing` justifies the lobby line
+by saying it is "supposed to start a rivalry", and only the solo view existed.
+
+**This is a deliberate reversal of #371, not an extension of it.** That issue
+sends each player only their OWN standing; #624 kept that posture this
+afternoon. A head-to-head on the TV puts two people's record in front of the
+whole room. Markus made that call explicitly — at home it is the fun, at a party
+with colleagues "Ben 0–5" can be the moment someone stops playing. The phones
+therefore stay out of it: TV and admin only.
+
+Scope is the detailed history, which prunes at RETENTION_DAYS /
+MAX_DETAILED_RECORDS, so the line says "last 90 days" rather than implying an
+all-time record it cannot support.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_CC = _REPO_ROOT / "custom_components" / "quizify"
+_WWW = _CC / "www"
+
+
+class _Analytics:
+ def __init__(self, games: list[dict]) -> None:
+ from custom_components.quizify.analytics import QuizifyAnalytics
+
+ self._impl = QuizifyAnalytics.__new__(QuizifyAnalytics)
+ self._impl._data = {"games": games}
+
+ def duel(self, present: list[str]):
+ return self._impl.get_head_to_head(present)
+
+
+def _game(scores: dict[str, int]) -> dict:
+ return {"player_scores": scores}
+
+
+def test_a_single_shared_game_is_not_a_record() -> None:
+ """"1–0" after one game reads like a record and is a coincidence."""
+ games = [_game({"Anna": 10, "Ben": 8})]
+
+ assert _Analytics(games).duel(["Anna", "Ben"]) is None
+
+
+def test_two_meetings_produce_the_duel() -> None:
+ games = [
+ _game({"Anna": 10, "Ben": 8}),
+ _game({"Anna": 5, "Ben": 9}),
+ _game({"Anna": 7, "Ben": 3}),
+ ]
+
+ duel = _Analytics(games).duel(["Anna", "Ben"])
+
+ assert duel == {
+ "left": "Anna",
+ "right": "Ben",
+ "left_wins": 2,
+ "right_wins": 1,
+ "games": 3,
+ }
+
+
+def test_the_winner_of_a_meeting_is_between_those_two() -> None:
+ """Not the game's overall winner.
+
+ Cara topping the table does not settle anything between Anna and Ben.
+ """
+ games = [
+ _game({"Anna": 4, "Ben": 3, "Cara": 99}),
+ _game({"Anna": 6, "Ben": 2, "Cara": 99}),
+ ]
+
+ duel = _Analytics(games).duel(["Anna", "Ben"])
+
+ assert duel is not None
+ assert (duel["left_wins"], duel["right_wins"]) == (2, 0)
+
+
+def test_a_draw_counts_as_a_meeting_and_goes_to_nobody() -> None:
+ games = [
+ _game({"Anna": 5, "Ben": 5}),
+ _game({"Anna": 7, "Ben": 3}),
+ ]
+
+ duel = _Analytics(games).duel(["Anna", "Ben"])
+
+ assert duel is not None
+ assert duel["games"] == 2
+ assert (duel["left_wins"], duel["right_wins"]) == (1, 0)
+
+
+def test_the_most_met_pair_wins_when_several_are_present() -> None:
+ """Three regulars in the lobby is one duel, not three."""
+ games = [
+ _game({"Anna": 9, "Ben": 4}),
+ _game({"Anna": 8, "Ben": 5}),
+ _game({"Anna": 7, "Ben": 6}),
+ _game({"Anna": 3, "Cara": 9}),
+ _game({"Anna": 2, "Cara": 8}),
+ ]
+
+ duel = _Analytics(games).duel(["Anna", "Ben", "Cara"])
+
+ assert duel is not None
+ assert {duel["left"], duel["right"]} == {"Anna", "Ben"}
+ assert duel["games"] == 3
+
+
+def test_games_only_one_of_them_played_are_ignored() -> None:
+ games = [
+ _game({"Anna": 10}),
+ _game({"Ben": 10}),
+ _game({"Anna": 10, "Ben": 1}),
+ ]
+
+ assert _Analytics(games).duel(["Anna", "Ben"]) is None
+
+
+def test_a_lobby_of_one_has_no_duel() -> None:
+ assert _Analytics([_game({"Anna": 1, "Ben": 2})]).duel(["Anna"]) is None
+
+
+def test_it_is_sent_in_the_lobby_only_and_never_to_phones() -> None:
+ """The reversal of #371 is bounded: the room sees it, the phones do not."""
+ source = (_CC / "server" / "websocket.py").read_text("utf-8")
+ body = source.split("async def _send_head_to_head", 1)[1].split(
+ "\n def ", 1
+ )[0]
+ body = re.sub(r'""".*?"""', "", body, flags=re.S)
+
+ assert "phase != GamePhase.LOBBY" in body
+ assert "broadcast_to_admins_and_dashboards" in body
+ # A plain broadcast would reach every phone — the thing #371 avoided.
+ assert "self._conn.broadcast(" not in body
+
+
+def test_the_tv_states_the_ninety_day_scope() -> None:
+ """The detailed history prunes, so calling it all-time would be a claim the
+ data cannot support."""
+ html = (_WWW / "dashboard.html").read_text("utf-8")
+
+ assert "dashboard.h2hRecent" in html
+ for code in ("de", "en", "es"):
+ bundle = json.loads((_WWW / "i18n" / f"{code}.json").read_text("utf-8"))
+ dash = bundle["dashboard"]
+ assert dash.get("h2hLabel")
+ assert "90" in dash["h2hRecent"]
+
+
+def test_the_names_are_escaped() -> None:
+ html = (_WWW / "dashboard.html").read_text("utf-8")
+ body = html.split("function handleHeadToHead(", 1)[1].split("\n }", 1)[0]
+
+ assert "escapeHtml(msg.left)" in body
+ assert "escapeHtml(msg.right)" in body