diff --git a/app/lib/__tests__/unmatched-team-reconciliation.test.ts b/app/lib/__tests__/unmatched-team-reconciliation.test.ts new file mode 100644 index 0000000..068b049 --- /dev/null +++ b/app/lib/__tests__/unmatched-team-reconciliation.test.ts @@ -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", + ]); + }); +}); diff --git a/app/lib/unmatched-team-reconciliation.ts b/app/lib/unmatched-team-reconciliation.ts new file mode 100644 index 0000000..225ea9e --- /dev/null +++ b/app/lib/unmatched-team-reconciliation.ts @@ -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 { + 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, + }; +} diff --git a/app/routes/__tests__/admin.sports-seasons.$id.test.ts b/app/routes/__tests__/admin.sports-seasons.$id.test.ts new file mode 100644 index 0000000..7910066 --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.$id.test.ts @@ -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(); + }); +}); diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index 9e05cbe..fb84899 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -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; + 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(sportsSeason.scoringPattern || ""); + const pendingMappingsWithSuggestions = pendingMappings.map((mapping) => ({ + ...mapping, + standingData: (mapping.standingData ?? {}) as PendingStandingData, + resolutionView: buildUnmatchedTeamResolutionView(mapping.teamName, participants), + })); return (
@@ -738,41 +814,173 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo Unmatched Teams ({pendingMappings.length}) - 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. - + +
+ + Exact match + + + Partial match + + + Needs review + +
{actionData?.success && actionData.intent === "resolve-mapping" && (
- Resolved: {actionData.resolvedTeam} + Resolved "{actionData.resolvedTeam}" to "{actionData.resolvedParticipant}"
)} - {pendingMappings.map((mapping) => ( -
- - - -
-

{mapping.teamName}

-

ID: {mapping.externalTeamId}

-
- - -
+ {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 ( +
+ + + +
+
+
+
+

{mapping.teamName}

+ + {getConfidenceLabel(mapping.resolutionView.confidence)} + +
+

+ Feed ID: {mapping.externalTeamId} +

+
+ +
+ {typeof mapping.standingData.leagueRank === "number" && ( + League rank #{mapping.standingData.leagueRank} + )} + {(mapping.standingData.conference || mapping.standingData.division) && ( + + {[mapping.standingData.conference, mapping.standingData.division].filter(Boolean).join(" - ")} + + )} + {typeof mapping.standingData.wins === "number" && typeof mapping.standingData.losses === "number" && ( + Record {formatTeamRecord(mapping.standingData)} + )} + {typeof mapping.standingData.gamesPlayed === "number" && ( + {mapping.standingData.gamesPlayed} GP + )} + {mapping.standingData.streak && ( + Streak {mapping.standingData.streak} + )} + {mapping.standingData.lastTen && ( + Last 10 {mapping.standingData.lastTen} + )} +
+ +

+ No participant name matched this API team. +

+
+ +
+
+
+

+ Suggested participant +

+ + {getConfidenceLabel(mapping.resolutionView.confidence)} + +
+

+ {mapping.resolutionView.suggestedParticipantName ?? "No strong suggestion"} +

+ {mapping.resolutionView.topCandidates.length > 0 && ( +
+ Top matches:{" "} + {mapping.resolutionView.topCandidates + .map((candidate) => candidate.participantName) + .join(", ")} +
+ )} +
+ +
+ + +
+ + +
+
+
+ ); + })() ))}