From 1d4c9a3b899b487e7da7af39cda5c93a0a9bdf83 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Thu, 6 Aug 2026 07:20:34 +0300
Subject: [PATCH] Add set dividers
---
app/features/scanner/components/LivePage.tsx | 77 ++++++-----
app/features/scanner/components/MatchCard.tsx | 5 +
app/features/scanner/components/VodPage.tsx | 74 ++++++-----
app/features/scanner/components/styles.css | 23 +++-
app/features/scanner/core/match-sets.ts | 66 ++++++++++
app/features/scanner/tests/match-sets.test.ts | 123 ++++++++++++++++++
6 files changed, 304 insertions(+), 64 deletions(-)
create mode 100644 app/features/scanner/core/match-sets.ts
create mode 100644 app/features/scanner/tests/match-sets.test.ts
diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx
index 0ca736126..fcc069f2f 100644
--- a/app/features/scanner/components/LivePage.tsx
+++ b/app/features/scanner/components/LivePage.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import {
listVideoInputs,
openVirtualCamera,
@@ -11,6 +11,7 @@ import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
+import { assignMatchSets } from "../core/match-sets";
import { TimelineBuilder } from "../core/timeline/index";
import {
clearEvents,
@@ -27,7 +28,7 @@ import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
-import { MatchCard } from "./MatchCard";
+import { MatchCard, SetDivider } from "./MatchCard";
import {
aggregateSendStatus,
matchContaining,
@@ -208,6 +209,8 @@ export function LivePage({
}, [deviceId, refreshFeed, send]);
const builtMatches = buildScannerMatches(feed);
+ const setNumbers = assignMatchSets(builtMatches.map((b) => b.match));
+ const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedFeed = feed.filter((e) => !groupedEvents.has(e));
@@ -319,41 +322,47 @@ export function LivePage({
No detections yet.
) : null}
{[...builtMatches].reverse().map((built, reverseIndex) => {
+ const index = builtMatches.length - 1 - reverseIndex;
const id = built.sources[0]!.id!;
const ingestable = isIngestableMatch(built.match);
return (
- void send(matchContaining(id), { manual: true })
- : undefined
- }
- >
- {withoutRepeatEvents(built.sources).map((e) => (
- loadEventFrame(e.id!)
- : undefined
- }
- />
- ))}
-
+
+ {showSetDividers &&
+ setNumbers[index + 1] !== setNumbers[index] ? (
+
+ ) : null}
+ void send(matchContaining(id), { manual: true })
+ : undefined
+ }
+ >
+ {withoutRepeatEvents(built.sources).map((e) => (
+ loadEventFrame(e.id!)
+ : undefined
+ }
+ />
+ ))}
+
+
);
})}
{ungroupedFeed.length > 0 ? (
diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx
index b7fd31788..0ba6c4286 100644
--- a/app/features/scanner/components/MatchCard.tsx
+++ b/app/features/scanner/components/MatchCard.tsx
@@ -145,6 +145,11 @@ export function MatchCard({
);
}
+/** Labeled rule above the newest card of each set in the feed. */
+export function SetDivider({ number }: { number: number }) {
+ return Set {number}
;
+}
+
function timeRangeLabel(match: ScannerMatch): string {
const start = formatTime(match.startsAt!);
return match.endsAt !== null && match.endsAt !== match.startsAt
diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx
index 1215dac78..7023b80c6 100644
--- a/app/features/scanner/components/VodPage.tsx
+++ b/app/features/scanner/components/VodPage.tsx
@@ -12,12 +12,20 @@
* Completed scans are persisted to IndexedDB keyed by file name
* (src/store/vods.ts); the default view lists them for reinspection.
*/
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Fragment,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { Link } from "react-router";
import { openVodScan } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import type { DetectedEvent } from "../core/detectors/types";
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
+import { assignMatchSets } from "../core/match-sets";
import { TimelineBuilder } from "../core/timeline/index";
import type { SendStatus } from "../store/events";
import {
@@ -36,7 +44,7 @@ import { downloadEventsCsv } from "./events-csv";
import type { FixtureData } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
import { formatTime } from "./format";
-import { MatchCard } from "./MatchCard";
+import { MatchCard, SetDivider } from "./MatchCard";
import {
countIngestableMatches,
type SendouUser,
@@ -131,6 +139,8 @@ export function VodPage({
);
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
+ const setNumbers = assignMatchSets(builtMatches.map((b) => b.match));
+ const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event));
@@ -578,35 +588,41 @@ export function VodPage({
) : null}
{/* newest match on top; the builder keeps ascending video-time order */}
{[...builtMatches].reverse().map((built, reverseIndex) => {
+ const index = builtMatches.length - 1 - reverseIndex;
const ingestable = isIngestableMatch(built.match);
return (
-
- {withoutRepeatEvents(built.sources).map((e, i) => {
- const vodMatch = vodMatchByEvent.get(e);
- return (
-
- );
- })}
-
+
+ {showSetDividers &&
+ setNumbers[index + 1] !== setNumbers[index] ? (
+
+ ) : null}
+
+ {withoutRepeatEvents(built.sources).map((e, i) => {
+ const vodMatch = vodMatchByEvent.get(e);
+ return (
+
+ );
+ })}
+
+
);
})}
{ungroupedMatches.length > 0 ? (
diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css
index aacf93e22..6a503c708 100644
--- a/app/features/scanner/components/styles.css
+++ b/app/features/scanner/components/styles.css
@@ -626,6 +626,26 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
/* ── ingested matches feed ─────────────────────────────────────────── */
+.scanner-app .set-divider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 6px;
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-semi);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+
+ &::before,
+ &::after {
+ content: "";
+ flex: 1;
+ height: var(--border-width);
+ background-color: var(--color-border);
+ }
+}
+
.scanner-app .match-card {
position: sticky;
top: var(--layout-sticky-top);
@@ -770,7 +790,8 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
padding: var(--s-0-5);
&.pov {
- outline: 2px solid var(--color-text-accent);
+ outline: var(--border-style-high);
+ background-color: var(--color-bg-high);
outline-offset: 1px;
}
}
diff --git a/app/features/scanner/core/match-sets.ts b/app/features/scanner/core/match-sets.ts
new file mode 100644
index 000000000..98cfd14b5
--- /dev/null
+++ b/app/features/scanner/core/match-sets.ts
@@ -0,0 +1,66 @@
+/**
+ * Divide a chronological run of ScannerMatches into sets — consecutive games
+ * played by the same eight players (a Bo3/Bo5 between two teams). Rosters
+ * are compared by in-game names pooled across both teams (scoreboards list
+ * winners first, so team sides swap between games): names match fuzzily
+ * because two OCR reads of the same name can differ by a glyph or two, and
+ * one differing player is tolerated as a sub between games. A match with no
+ * readable names is inconclusive and never opens a new set.
+ */
+import type { ScannerMatch } from "./scanner-match";
+import { closestBy } from "./text";
+
+/**
+ * closestBy score two reads of the same name must reach — 0.7 forgives one
+ * bad glyph on a four-letter name, two on longer ones.
+ */
+const SAME_NAME_SCORE = 0.7;
+
+/** Players allowed to differ between consecutive games of one set. */
+const MAX_SUBS_PER_GAME = 1;
+
+/**
+ * For each match its 1-based set number, aligned by index with the input;
+ * matches must be in chronological order. Numbers only ever step up by one:
+ * a match whose roster disagrees with the current set's opens the next set.
+ */
+export function assignMatchSets(matches: readonly ScannerMatch[]): number[] {
+ const setNumbers: number[] = [];
+ let setNumber = 1;
+ let roster: string[] | null = null;
+ for (const match of matches) {
+ const names = rosterNames(match);
+ if (names.length > 0) {
+ if (roster !== null && !sameRoster(roster, names)) setNumber++;
+ roster = names;
+ }
+ setNumbers.push(setNumber);
+ }
+ return setNumbers;
+}
+
+function rosterNames(match: ScannerMatch): string[] {
+ return match.teams
+ .flatMap((team) => team.players)
+ .map((player) => player.name)
+ .filter((name): name is string => name !== null);
+}
+
+/**
+ * Whether two rosters read as the same eight players: every name of the
+ * smaller roster must find a fuzzy partner in the other, short of
+ * MAX_SUBS_PER_GAME misses. Partial reads compare only what both saw, so a
+ * minimap-sourced half-roster still chains a set together.
+ */
+function sameRoster(a: readonly string[], b: readonly string[]): boolean {
+ const remaining = [...b];
+ let matched = 0;
+ for (const name of a) {
+ const best = closestBy(name, remaining, (entry) => entry);
+ if (best && best.score >= SAME_NAME_SCORE) {
+ matched++;
+ remaining.splice(remaining.indexOf(best.entry), 1);
+ }
+ }
+ return Math.min(a.length, b.length) - matched <= MAX_SUBS_PER_GAME;
+}
diff --git a/app/features/scanner/tests/match-sets.test.ts b/app/features/scanner/tests/match-sets.test.ts
new file mode 100644
index 000000000..30128eccf
--- /dev/null
+++ b/app/features/scanner/tests/match-sets.test.ts
@@ -0,0 +1,123 @@
+import assert from "node:assert/strict";
+import { assignMatchSets } from "../core/match-sets";
+import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match";
+import test from "./node-test-compat";
+
+const TEAM_A = ["Sendou", "Kiver", "Brian", "Zed"];
+const TEAM_B = ["Gos", "Noah", "Alice", "Bob"];
+const TEAM_C = ["Totoro", "Miso", "Ramen", "Udon"];
+const TEAM_D = ["Pearl", "Marina", "Callie", "Marie"];
+
+function player(name: string | null): ScannerMatchPlayer {
+ return { name, weaponId: null, paint: null, ka: null, d: null, s: null };
+}
+
+function match(
+ alpha: (string | null)[],
+ bravo: (string | null)[],
+): ScannerMatch {
+ return {
+ startsAt: null,
+ endsAt: null,
+ playedAt: null,
+ lobby: null,
+ mode: null,
+ stage: null,
+ matchScores: null,
+ replayCode: null,
+ cast: false,
+ teams: [{ players: alpha.map(player) }, { players: bravo.map(player) }],
+ winner: null,
+ pov: null,
+ };
+}
+
+test("assignMatchSets", async (t) => {
+ await t.test("empty input", () => {
+ assert.deepEqual(assignMatchSets([]), []);
+ });
+
+ await t.test("same eight players stay in one set", () => {
+ const games = [
+ match(TEAM_A, TEAM_B),
+ match(TEAM_A, TEAM_B),
+ match(TEAM_A, TEAM_B),
+ ];
+ assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
+ });
+
+ await t.test("team sides swapping keeps the set together", () => {
+ const games = [match(TEAM_A, TEAM_B), match(TEAM_B, TEAM_A)];
+ assert.deepEqual(assignMatchSets(games), [1, 1]);
+ });
+
+ await t.test("a fully different lobby opens a new set", () => {
+ const games = [
+ match(TEAM_A, TEAM_B),
+ match(TEAM_A, TEAM_B),
+ match(TEAM_C, TEAM_D),
+ match(TEAM_C, TEAM_D),
+ ];
+ assert.deepEqual(assignMatchSets(games), [1, 1, 2, 2]);
+ });
+
+ await t.test("OCR stutter on a couple of names is tolerated", () => {
+ const games = [
+ match(TEAM_A, TEAM_B),
+ match(
+ ["Send0u", "Kiver", "Brian", "Zed"],
+ ["Gos", "N0ah", "Alice", "Bob"],
+ ),
+ ];
+ assert.deepEqual(assignMatchSets(games), [1, 1]);
+ });
+
+ await t.test("one sub is tolerated, two are a new set", () => {
+ const oneSub = [
+ match(TEAM_A, TEAM_B),
+ match(["Sendou", "Kiver", "Brian", "NewGuy"], TEAM_B),
+ ];
+ assert.deepEqual(assignMatchSets(oneSub), [1, 1]);
+
+ const twoSubs = [
+ match(TEAM_A, TEAM_B),
+ match(["Sendou", "Kiver", "Sub1", "Sub2"], TEAM_B),
+ ];
+ assert.deepEqual(assignMatchSets(twoSubs), [1, 2]);
+ });
+
+ await t.test("a nameless match never breaks the chain", () => {
+ const games = [
+ match(TEAM_A, TEAM_B),
+ match([null, null, null, null], [null, null, null, null]),
+ match(TEAM_A, TEAM_B),
+ ];
+ assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
+ });
+
+ await t.test("a partial roster read chains on what both saw", () => {
+ const games = [
+ match(TEAM_A, TEAM_B),
+ match(["Sendou", "Kiver", null, null], [null, null, null, null]),
+ match(TEAM_A, TEAM_B),
+ ];
+ assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
+ });
+
+ await t.test("a partial read of different players opens a new set", () => {
+ const games = [
+ match(TEAM_A, TEAM_B),
+ match(["Totoro", "Miso", "Ramen", null], [null, null, null, null]),
+ ];
+ assert.deepEqual(assignMatchSets(games), [1, 2]);
+ });
+
+ await t.test("comparison follows the latest roster, not the first", () => {
+ const games = [
+ match(TEAM_A, TEAM_B),
+ match(["Sendou", "Kiver", "Brian", "SubA"], TEAM_B),
+ match(["Sendou", "Kiver", "Brian", "SubB"], TEAM_B),
+ ];
+ assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
+ });
+});