Improve unmatched team reconciliation UX

This commit is contained in:
Chris Parsons 2026-05-14 16:37:18 -07:00
parent 766ba948e1
commit 5d3cea922a
4 changed files with 581 additions and 31 deletions

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
buildUnmatchedTeamResolutionView,
rankParticipantMatches,
type ReconciliationParticipant,
} from "../unmatched-team-reconciliation";
const participants: ReconciliationParticipant[] = [
{ id: "1", name: "Los Angeles Lakers" },
{ id: "2", name: "Golden State Warriors" },
{ id: "3", name: "Boston Celtics" },
{ id: "4", name: "New York Knicks" },
];
describe("unmatched team reconciliation helpers", () => {
it("suggests an exact normalized match first", () => {
const result = buildUnmatchedTeamResolutionView("los angeles lakers", participants);
expect(result.confidence).toBe("exact");
expect(result.suggestedParticipantName).toBe("Los Angeles Lakers");
expect(result.suggestedParticipantId).toBe("1");
});
it("suggests a substring match when an exact match is absent", () => {
const result = buildUnmatchedTeamResolutionView("Golden State", participants);
expect(result.confidence).toBe("partial");
expect(result.suggestedParticipantName).toBe("Golden State Warriors");
expect(result.orderedParticipants[0]?.name).toBe("Golden State Warriors");
});
it("leaves the picker unselected when only review-ranked matches exist", () => {
const result = buildUnmatchedTeamResolutionView("Boston Club", participants);
expect(result.confidence).toBe("review");
expect(result.suggestedParticipantId).toBeNull();
expect(result.topCandidates.length).toBeGreaterThan(0);
});
it("keeps top candidates ahead of the full participant list", () => {
const ranked = rankParticipantMatches("Boston", participants);
const result = buildUnmatchedTeamResolutionView("Boston", participants);
expect(ranked[0]?.participantName).toBe("Boston Celtics");
expect(result.orderedParticipants[0]?.name).toBe("Boston Celtics");
expect(result.orderedParticipants.map((participant) => participant.id)).toEqual([
"3",
"1",
"2",
"4",
]);
});
});

View file

@ -0,0 +1,154 @@
import { normalizeTeamName } from "~/lib/normalize-team-name";
export type ReconciliationParticipant = {
id: string;
name: string;
externalId?: string | null;
};
export type MatchConfidence = "exact" | "partial" | "review" | "none";
export type CandidateMatch = {
participantId: string;
participantName: string;
confidence: MatchConfidence;
score: number;
};
export type UnmatchedTeamResolutionView = {
suggestedParticipantId: string | null;
suggestedParticipantName: string | null;
confidence: MatchConfidence;
topCandidates: CandidateMatch[];
orderedParticipants: ReconciliationParticipant[];
};
function tokenSet(value: string): Set<string> {
return new Set(
normalizeTeamName(value)
.split(" ")
.map((token) => token.trim())
.filter(Boolean)
);
}
function reviewScore(apiTeamName: string, participantName: string): number {
const normalizedApi = normalizeTeamName(apiTeamName);
const normalizedParticipant = normalizeTeamName(participantName);
if (!normalizedApi || !normalizedParticipant) return 0;
const apiTokens = tokenSet(apiTeamName);
const participantTokens = tokenSet(participantName);
const sharedTokens = [...apiTokens].filter((token) => participantTokens.has(token));
let score = 0;
if (sharedTokens.length > 0) {
score += sharedTokens.length * 20;
}
if (normalizedApi.startsWith(normalizedParticipant) || normalizedParticipant.startsWith(normalizedApi)) {
score += 12;
}
if (normalizedApi[0] === normalizedParticipant[0]) {
score += 4;
}
const lengthDelta = Math.abs(normalizedApi.length - normalizedParticipant.length);
score -= Math.min(lengthDelta, 10);
return Math.max(score, 0);
}
export function rankParticipantMatches(
apiTeamName: string,
participants: ReconciliationParticipant[]
): CandidateMatch[] {
const normalizedApi = normalizeTeamName(apiTeamName);
return participants
.map((participant) => {
const normalizedParticipant = normalizeTeamName(participant.name);
if (!normalizedApi || !normalizedParticipant) {
return {
participantId: participant.id,
participantName: participant.name,
confidence: "none" as const,
score: 0,
};
}
if (normalizedParticipant === normalizedApi) {
return {
participantId: participant.id,
participantName: participant.name,
confidence: "exact" as const,
score: 100,
};
}
if (
normalizedApi.length >= 4 &&
normalizedParticipant.length >= 4 &&
(normalizedApi.includes(normalizedParticipant) ||
normalizedParticipant.includes(normalizedApi))
) {
return {
participantId: participant.id,
participantName: participant.name,
confidence: "partial" as const,
score: 80,
};
}
return {
participantId: participant.id,
participantName: participant.name,
confidence: "review" as const,
score: reviewScore(apiTeamName, participant.name),
};
})
.toSorted((a, b) => b.score - a.score || a.participantName.localeCompare(b.participantName));
}
export function buildUnmatchedTeamResolutionView(
apiTeamName: string,
participants: ReconciliationParticipant[]
): UnmatchedTeamResolutionView {
const rankedCandidates = rankParticipantMatches(apiTeamName, participants);
const topCandidates = rankedCandidates.filter((candidate) => candidate.score > 0).slice(0, 3);
const bestCandidate = rankedCandidates[0];
const confidence =
bestCandidate?.confidence === "exact" || bestCandidate?.confidence === "partial"
? bestCandidate.confidence
: topCandidates.length > 0
? "review"
: "none";
const suggestedParticipantId =
confidence === "exact" || confidence === "partial" ? bestCandidate.participantId : null;
const suggestedParticipantName =
confidence === "none" ? null : (bestCandidate?.participantName ?? null);
const topCandidateIds = new Set(topCandidates.map((candidate) => candidate.participantId));
const orderedParticipants = [
...topCandidates
.map((candidate) =>
participants.find((participant) => participant.id === candidate.participantId) ?? null
)
.filter((participant): participant is ReconciliationParticipant => participant !== null),
...participants.filter((participant) => !topCandidateIds.has(participant.id)),
];
return {
suggestedParticipantId,
suggestedParticipantName,
confidence,
topCandidates,
orderedParticipants,
};
}

View file

@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RouterContextProvider } from "react-router";
const ctx = {} as unknown as RouterContextProvider;
let action: any;
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },
}));
vi.mock("~/models/user", () => ({
isUserAdmin: vi.fn(),
}));
vi.mock("~/models/sports-season", () => ({
updateSportsSeason: vi.fn(),
findSportsSeasonById: vi.fn(),
deleteSportsSeason: vi.fn(),
}));
vi.mock("~/models/pending-standings-mappings", () => ({
getPendingStandingsMappings: vi.fn(),
deletePendingStandingsMapping: vi.fn(),
}));
vi.mock("~/models/season-participant", () => ({
findParticipantsBySportsSeasonId: vi.fn(),
findParticipantById: vi.fn(),
updateParticipant: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getLastSyncedAt: vi.fn(),
upsertRegularSeasonStandings: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", () => ({
processSeasonStandings: vi.fn(),
recalculateStandings: vi.fn(),
}));
vi.mock("~/models/standings", () => ({
createDailySnapshot: vi.fn(),
}));
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/standings-sync/index", () => ({
syncStandings: vi.fn(),
}));
vi.mock("~/services/simulations/registry", () => ({
getSimulatorInfo: vi.fn(),
SIMULATOR_TYPES: [],
}));
function makeResolveRequest() {
const formData = new FormData();
formData.append("intent", "resolve-mapping");
formData.append("externalTeamId", "ext-42");
formData.append("participantId", "participant-7");
formData.append(
"standingData",
JSON.stringify({
teamName: "Golden State",
wins: 48,
losses: 20,
winPct: 0.706,
gamesPlayed: 68,
leagueRank: 3,
})
);
return new Request("http://localhost/admin/sports-seasons/season-1", {
method: "POST",
body: formData,
});
}
describe("admin.sports-seasons.$id action", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
const { auth } = await import("~/lib/auth.server");
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: "admin-1" } } as any);
const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdmin).mockResolvedValue(true);
const { findParticipantById, updateParticipant } = await import("~/models/season-participant");
vi.mocked(findParticipantById).mockResolvedValue({
id: "participant-7",
name: "Golden State Warriors",
sportsSeasonId: "season-1",
} as any);
vi.mocked(updateParticipant).mockResolvedValue({ id: "participant-7" } as any);
const { upsertRegularSeasonStandings } = await import("~/models/regular-season-standings");
vi.mocked(upsertRegularSeasonStandings).mockResolvedValue(undefined as never);
const { deletePendingStandingsMapping } = await import("~/models/pending-standings-mappings");
vi.mocked(deletePendingStandingsMapping).mockResolvedValue(undefined as never);
const routeModule = await import("../admin.sports-seasons.$id");
action = routeModule.action;
});
it("returns the resolved API team and participant name after a mapping is confirmed", async () => {
const result = await action({
request: makeResolveRequest(),
params: { id: "season-1" },
context: ctx,
});
expect(result).toMatchObject({
success: true,
intent: "resolve-mapping",
resolvedTeam: "Golden State",
resolvedParticipant: "Golden State Warriors",
});
});
it("rejects a participant from a different sports season", async () => {
const { findParticipantById, updateParticipant } = await import("~/models/season-participant");
vi.mocked(findParticipantById).mockResolvedValue({
id: "participant-7",
name: "Golden State Warriors",
sportsSeasonId: "other-season",
} as any);
const result = await action({
request: makeResolveRequest(),
params: { id: "season-1" },
context: ctx,
});
expect(result).toMatchObject({
error: "Selected participant does not belong to this sports season.",
});
expect(vi.mocked(updateParticipant)).not.toHaveBeenCalled();
});
});

View file

@ -16,8 +16,13 @@ import {
getPendingStandingsMappings, getPendingStandingsMappings,
deletePendingStandingsMapping, deletePendingStandingsMapping,
} from "~/models/pending-standings-mappings"; } from "~/models/pending-standings-mappings";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant"; import {
findParticipantById,
findParticipantsBySportsSeasonId,
updateParticipant,
} from "~/models/season-participant";
import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings"; import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings";
import { buildUnmatchedTeamResolutionView, type MatchConfidence } from "~/lib/unmatched-team-reconciliation";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@ -40,11 +45,67 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react"; import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"; const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
type PendingStandingData = {
teamName?: string;
wins?: number;
losses?: number;
otLosses?: number | null;
ties?: number | null;
gamesPlayed?: number;
conference?: string | null;
division?: string | null;
leagueRank?: number;
streak?: string | null;
lastTen?: string | null;
};
function getConfidenceBadgeVariant(confidence: MatchConfidence) {
switch (confidence) {
case "exact":
return "bg-emerald-500/15 text-emerald-400 border-emerald-500/30";
case "partial":
return "bg-sky-500/15 text-sky-400 border-sky-500/30";
case "review":
return "bg-amber-500/15 text-amber-500 border-amber-500/30";
default:
return "bg-muted text-muted-foreground border-border";
}
}
function getConfidenceLabel(confidence: MatchConfidence) {
switch (confidence) {
case "exact":
return "Exact match";
case "partial":
return "Partial match";
case "review":
return "Needs review";
default:
return "No suggestion";
}
}
function formatTeamRecord(standingData: PendingStandingData) {
const wins = standingData.wins ?? 0;
const losses = standingData.losses ?? 0;
if (typeof standingData.otLosses === "number") {
return `${wins}-${losses}-${standingData.otLosses}`;
}
if (typeof standingData.ties === "number") {
return `${wins}-${losses}-${standingData.ties}`;
}
return `${wins}-${losses}`;
}
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
} }
@ -158,6 +219,11 @@ export async function action(args: Route.ActionArgs) {
try { try {
const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>; const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>;
const participant = await findParticipantById(participantId);
if (!participant || participant.sportsSeasonId !== params.id) {
return { error: "Selected participant does not belong to this sports season." };
}
// Write externalId onto the participant for future ID-first matching // Write externalId onto the participant for future ID-first matching
await updateParticipant(participantId, { externalId: externalTeamId }); await updateParticipant(participantId, { externalId: externalTeamId });
@ -191,7 +257,12 @@ export async function action(args: Route.ActionArgs) {
// Remove from pending queue // Remove from pending queue
await deletePendingStandingsMapping(params.id, externalTeamId); await deletePendingStandingsMapping(params.id, externalTeamId);
return { success: true, intent: "resolve-mapping", resolvedTeam: String(standingData.teamName ?? "") }; return {
success: true,
intent: "resolve-mapping",
resolvedTeam: String(standingData.teamName ?? ""),
resolvedParticipant: participant?.name ?? "",
};
} catch (error) { } catch (error) {
logger.error("Error resolving mapping:", error); logger.error("Error resolving mapping:", error);
return { error: "Failed to resolve mapping. Please try again." }; return { error: "Failed to resolve mapping. Please try again." };
@ -300,6 +371,11 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
navigation.state === "submitting" && navigation.state === "submitting" &&
(navigation.formData?.get("intent") as string) === "sync-standings"; (navigation.formData?.get("intent") as string) === "sync-standings";
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || ""); const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
const pendingMappingsWithSuggestions = pendingMappings.map((mapping) => ({
...mapping,
standingData: (mapping.standingData ?? {}) as PendingStandingData,
resolutionView: buildUnmatchedTeamResolutionView(mapping.teamName, participants),
}));
return ( return (
<div className="p-8"> <div className="p-8">
@ -738,41 +814,173 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
Unmatched Teams ({pendingMappings.length}) Unmatched Teams ({pendingMappings.length})
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
These teams from the last sync could not be automatically matched to a participant. These teams came back from the official standings feed but could not be tied to a
Assign each one to the correct participant to resolve. participant in this season. Use the standings details and suggested participant to
confirm the right match. Once resolved, future syncs will reuse the saved external ID.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-4">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className={getConfidenceBadgeVariant("exact")}>
Exact match
</Badge>
<Badge variant="outline" className={getConfidenceBadgeVariant("partial")}>
Partial match
</Badge>
<Badge variant="outline" className={getConfidenceBadgeVariant("review")}>
Needs review
</Badge>
</div>
{actionData?.success && actionData.intent === "resolve-mapping" && ( {actionData?.success && actionData.intent === "resolve-mapping" && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm"> <div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
Resolved: {actionData.resolvedTeam} Resolved "{actionData.resolvedTeam}" to "{actionData.resolvedParticipant}"
</div> </div>
)} )}
{pendingMappings.map((mapping) => ( {pendingMappingsWithSuggestions.map((mapping) => (
<Form key={mapping.externalTeamId} method="post" className="flex items-center gap-3"> (() => {
<input type="hidden" name="intent" value="resolve-mapping" /> const topCandidateIds = new Set(
<input type="hidden" name="externalTeamId" value={mapping.externalTeamId} /> mapping.resolutionView.topCandidates.map((candidate) => candidate.participantId)
<input );
type="hidden" const remainingParticipants = mapping.resolutionView.orderedParticipants.filter(
name="standingData" (participant) => !topCandidateIds.has(participant.id)
value={JSON.stringify(mapping.standingData)} );
/>
<div className="flex-1 min-w-0"> return (
<p className="text-sm font-medium truncate">{mapping.teamName}</p> <Form
<p className="text-xs text-muted-foreground">ID: {mapping.externalTeamId}</p> key={mapping.externalTeamId}
</div> method="post"
<select name="participantId" required defaultValue="" className={`${SELECT_CLASS} w-56`}> className="rounded-md border border-border/60 bg-muted/20 p-4 space-y-4"
<option value="" disabled>Select participant...</option> >
{participants.map((p) => ( <input type="hidden" name="intent" value="resolve-mapping" />
<option key={p.id} value={p.id}> <input type="hidden" name="externalTeamId" value={mapping.externalTeamId} />
{p.name} <input
</option> type="hidden"
))} name="standingData"
</select> value={JSON.stringify(mapping.standingData)}
<Button type="submit" size="sm"> />
Resolve <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
</Button> <div className="space-y-3 lg:max-w-sm">
</Form> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold">{mapping.teamName}</p>
<Badge
variant="outline"
className={getConfidenceBadgeVariant(mapping.resolutionView.confidence)}
>
{getConfidenceLabel(mapping.resolutionView.confidence)}
</Badge>
</div>
<p className="text-xs text-muted-foreground break-all">
Feed ID: {mapping.externalTeamId}
</p>
</div>
<div className="flex flex-wrap gap-2">
{typeof mapping.standingData.leagueRank === "number" && (
<Badge variant="secondary">League rank #{mapping.standingData.leagueRank}</Badge>
)}
{(mapping.standingData.conference || mapping.standingData.division) && (
<Badge variant="secondary">
{[mapping.standingData.conference, mapping.standingData.division].filter(Boolean).join(" - ")}
</Badge>
)}
{typeof mapping.standingData.wins === "number" && typeof mapping.standingData.losses === "number" && (
<Badge variant="secondary">Record {formatTeamRecord(mapping.standingData)}</Badge>
)}
{typeof mapping.standingData.gamesPlayed === "number" && (
<Badge variant="secondary">{mapping.standingData.gamesPlayed} GP</Badge>
)}
{mapping.standingData.streak && (
<Badge variant="secondary">Streak {mapping.standingData.streak}</Badge>
)}
{mapping.standingData.lastTen && (
<Badge variant="secondary">Last 10 {mapping.standingData.lastTen}</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
No participant name matched this API team.
</p>
</div>
<div className="space-y-3 lg:w-80">
<div className="rounded-md border border-border/60 bg-background/80 p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Suggested participant
</p>
<Badge
variant="outline"
className={getConfidenceBadgeVariant(mapping.resolutionView.confidence)}
>
{getConfidenceLabel(mapping.resolutionView.confidence)}
</Badge>
</div>
<p className="text-sm font-medium">
{mapping.resolutionView.suggestedParticipantName ?? "No strong suggestion"}
</p>
{mapping.resolutionView.topCandidates.length > 0 && (
<div className="text-xs text-muted-foreground">
Top matches:{" "}
{mapping.resolutionView.topCandidates
.map((candidate) => candidate.participantName)
.join(", ")}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`participant-${mapping.externalTeamId}`}>
Match to season participant
</Label>
<Select
name="participantId"
required
defaultValue={mapping.resolutionView.suggestedParticipantId ?? undefined}
>
<SelectTrigger
id={`participant-${mapping.externalTeamId}`}
className="w-full"
>
<SelectValue placeholder="Choose the correct participant" />
</SelectTrigger>
<SelectContent>
{mapping.resolutionView.topCandidates.length > 0 && (
<SelectGroup>
<SelectLabel>Suggested matches</SelectLabel>
{mapping.resolutionView.topCandidates.map((candidate) => (
<SelectItem
key={`${mapping.externalTeamId}-${candidate.participantId}`}
value={candidate.participantId}
>
{candidate.participantName}
</SelectItem>
))}
</SelectGroup>
)}
<SelectGroup>
<SelectLabel>All participants</SelectLabel>
{remainingParticipants.map((participant) => (
<SelectItem
key={`${mapping.externalTeamId}-all-${participant.id}`}
value={participant.id}
>
{participant.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" className="w-full sm:w-auto">
Confirm Match
</Button>
</div>
</div>
</Form>
);
})()
))} ))}
</CardContent> </CardContent>
</Card> </Card>