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,
deletePendingStandingsMapping,
} 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 { buildUnmatchedTeamResolutionView, type MatchConfidence } from "~/lib/unmatched-team-reconciliation";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
@ -40,11 +45,67 @@ import {
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
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 { useState } from "react";
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 {
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
@ -158,6 +219,11 @@ export async function action(args: Route.ActionArgs) {
try {
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
await updateParticipant(participantId, { externalId: externalTeamId });
@ -191,7 +257,12 @@ export async function action(args: Route.ActionArgs) {
// Remove from pending queue
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) {
logger.error("Error resolving mapping:", error);
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.formData?.get("intent") as string) === "sync-standings";
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
const pendingMappingsWithSuggestions = pendingMappings.map((mapping) => ({
...mapping,
standingData: (mapping.standingData ?? {}) as PendingStandingData,
resolutionView: buildUnmatchedTeamResolutionView(mapping.teamName, participants),
}));
return (
<div className="p-8">
@ -738,41 +814,173 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
Unmatched Teams ({pendingMappings.length})
</CardTitle>
<CardDescription>
These teams from the last sync could not be automatically matched to a participant.
Assign each one to the correct participant to resolve.
These teams came back from the official standings feed but could not be tied to a
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>
</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" && (
<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>
)}
{pendingMappings.map((mapping) => (
<Form key={mapping.externalTeamId} method="post" className="flex items-center gap-3">
<input type="hidden" name="intent" value="resolve-mapping" />
<input type="hidden" name="externalTeamId" value={mapping.externalTeamId} />
<input
type="hidden"
name="standingData"
value={JSON.stringify(mapping.standingData)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{mapping.teamName}</p>
<p className="text-xs text-muted-foreground">ID: {mapping.externalTeamId}</p>
</div>
<select name="participantId" required defaultValue="" className={`${SELECT_CLASS} w-56`}>
<option value="" disabled>Select participant...</option>
{participants.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button type="submit" size="sm">
Resolve
</Button>
</Form>
{pendingMappingsWithSuggestions.map((mapping) => (
(() => {
const topCandidateIds = new Set(
mapping.resolutionView.topCandidates.map((candidate) => candidate.participantId)
);
const remainingParticipants = mapping.resolutionView.orderedParticipants.filter(
(participant) => !topCandidateIds.has(participant.id)
);
return (
<Form
key={mapping.externalTeamId}
method="post"
className="rounded-md border border-border/60 bg-muted/20 p-4 space-y-4"
>
<input type="hidden" name="intent" value="resolve-mapping" />
<input type="hidden" name="externalTeamId" value={mapping.externalTeamId} />
<input
type="hidden"
name="standingData"
value={JSON.stringify(mapping.standingData)}
/>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3 lg:max-w-sm">
<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>
</Card>