Improve unmatched team reconciliation UX (#425)
This commit is contained in:
parent
766ba948e1
commit
9e9a37d4d2
4 changed files with 581 additions and 31 deletions
53
app/lib/__tests__/unmatched-team-reconciliation.test.ts
Normal file
53
app/lib/__tests__/unmatched-team-reconciliation.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
154
app/lib/unmatched-team-reconciliation.ts
Normal file
154
app/lib/unmatched-team-reconciliation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
135
app/routes/__tests__/admin.sports-seasons.$id.test.ts
Normal file
135
app/routes/__tests__/admin.sports-seasons.$id.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue