diff --git a/app/components/draft/ConnectionOverlay.tsx b/app/components/draft/ConnectionOverlay.tsx index f1f6916..47f7bda 100644 --- a/app/components/draft/ConnectionOverlay.tsx +++ b/app/components/draft/ConnectionOverlay.tsx @@ -5,15 +5,16 @@ interface ConnectionOverlayProps { isConnected: boolean; isReconnecting: boolean; connectionError: string | null; + isSyncing: boolean; } export function ConnectionOverlay({ isConnected, isReconnecting, connectionError, + isSyncing, }: ConnectionOverlayProps) { - // Don't show overlay if connected - if (isConnected) { + if (isConnected && !isSyncing) { return null; } @@ -51,15 +52,18 @@ export function ConnectionOverlay({

{connectionError ? "Connection Error" + : isSyncing + ? "Syncing Draft State..." : isReconnecting ? "Reconnecting..." : "Connecting to Draft"}

- {/* Message */}

{connectionError ? ( connectionError + ) : isSyncing ? ( + "Reconnected. Syncing the latest draft state..." ) : isReconnecting ? ( "Lost connection to the draft server. Attempting to reconnect..." ) : ( diff --git a/app/hooks/useDraftAuthRecovery.ts b/app/hooks/useDraftAuthRecovery.ts index fcb6988..46dee59 100644 --- a/app/hooks/useDraftAuthRecovery.ts +++ b/app/hooks/useDraftAuthRecovery.ts @@ -31,6 +31,7 @@ interface UseDraftAuthRecoveryParams { setQueue: (queue: QueueItem[]) => void; setTeamTimers: (fn: (prev: Record) => Record) => void; setAutodraftStatus: (fn: () => Record) => void; + setIsSyncing: (value: boolean) => void; } @@ -52,6 +53,7 @@ export function useDraftAuthRecovery({ setQueue, setTeamTimers, setAutodraftStatus, + setIsSyncing, }: UseDraftAuthRecoveryParams) { const [authDegraded, setAuthDegraded] = useState(false); @@ -192,9 +194,10 @@ export function useDraftAuthRecovery({ }); return status; }); + setIsSyncing(false); } }, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId, - setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus]); + setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus, setIsSyncing]); const authFetch = useCallback(async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init); diff --git a/app/hooks/useDraftRoomState.ts b/app/hooks/useDraftRoomState.ts index ff002a1..a370ed3 100644 --- a/app/hooks/useDraftRoomState.ts +++ b/app/hooks/useDraftRoomState.ts @@ -136,6 +136,7 @@ export function useDraftRoomState({ const [isOvernightPause, setIsOvernightPause] = useState(false); const [overnightResumesAt, setOvernightResumesAt] = useState(null); const [roomClosed, setRoomClosed] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); return { picks, setPicks, @@ -177,5 +178,6 @@ export function useDraftRoomState({ isOvernightPause, setIsOvernightPause, overnightResumesAt, setOvernightResumesAt, roomClosed, setRoomClosed, + isSyncing, setIsSyncing, }; } diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index 304b8a2..43efa25 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -40,6 +40,7 @@ interface UseDraftSocketEventsParams { setOvernightResumesAt: (value: Date | null) => void; setWatchedParticipantIds: (value: Set) => void; setRoomClosed: (value: boolean) => void; + setIsSyncing: (value: boolean) => void; } export function useDraftSocketEvents({ @@ -68,6 +69,7 @@ export function useDraftSocketEvents({ setOvernightResumesAt, setWatchedParticipantIds, setRoomClosed, + setIsSyncing, }: UseDraftSocketEventsParams) { useEffect(() => { type PickMadePayload = { @@ -225,6 +227,7 @@ export function useDraftSocketEvents({ setWatchedParticipantIds(new Set(data.watchlistParticipantIds)); } } + setIsSyncing(false); }; const handleWatchlistUpdated = (data: { participantIds: string[] }) => { diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 7515047..0b79451 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -254,6 +254,7 @@ export default function DraftRoom() { isOvernightPause, setIsOvernightPause, overnightResumesAt, setOvernightResumesAt, roomClosed, setRoomClosed, + isSyncing, setIsSyncing, } = useDraftRoomState({ draftPicks, season, @@ -335,8 +336,15 @@ export default function DraftRoom() { setQueue, setTeamTimers, setAutodraftStatus, + setIsSyncing, }); + useEffect(() => { + if (reconnectCount <= 0) return; + setIsSyncing(true); + const id = setTimeout(() => setIsSyncing(false), 5000); + return () => clearTimeout(id); + }, [reconnectCount, setIsSyncing]); // Shared transforms for eligibility calculations const transformedPicks = useMemo( @@ -463,6 +471,7 @@ export default function DraftRoom() { setOvernightResumesAt, setWatchedParticipantIds, setRoomClosed, + setIsSyncing, }); // Persist sidebar collapsed state @@ -1660,6 +1669,7 @@ export default function DraftRoom() { isConnected={isConnected} isReconnecting={isReconnecting} connectionError={connectionError} + isSyncing={isSyncing} /> {/* Auth Recovery Overlay - blocks interaction when session expires */} diff --git a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts index dd5dfa7..743c2a5 100644 --- a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts +++ b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts @@ -760,3 +760,169 @@ describe("autodraft and timer sync on revalidation completion", () => { expect(teamTimers["team-3"]).toBe(120); }); }); + +// --------------------------------------------------------------------------- +// 10. isSyncing state — reconnect sync indicator +// --------------------------------------------------------------------------- + +describe("isSyncing – reconnect sync indicator", () => { + it("sets isSyncing true when reconnectCount increments", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const reconnectCount = 1; + + if (reconnectCount > 0) { + setIsSyncing(true); + } + + expect(isSyncing).toBe(true); + }); + + it("does not set isSyncing on initial load (reconnectCount = 0)", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const reconnectCount = 0; + + if (reconnectCount > 0) { + setIsSyncing(true); + } + + expect(isSyncing).toBe(false); + }); + + it("clears isSyncing when draft-state-sync arrives (socket path)", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const isRevalidatingRef = { current: false }; + let picks: any[] = []; + const setPicks = (fn: (prev: any[]) => any[]) => { picks = fn(picks); }; + + const handleDraftStateSync = (data: any) => { + if (!isRevalidatingRef.current) { + setPicks(() => data.picks); + setIsSyncing(false); + } + }; + + handleDraftStateSync({ + currentPickNumber: 7, + isPaused: false, + status: "draft", + picks: [makePick(1, "team-1", "player-1")], + timers: [], + }); + + expect(isSyncing).toBe(false); + }); + + it("clears isSyncing even when draft-state-sync is skipped due to revalidation", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const isRevalidatingRef = { current: true }; + + const handleDraftStateSync = () => { + if (!isRevalidatingRef.current) { + // state updates skipped + } + setIsSyncing(false); + }; + + handleDraftStateSync(); + + expect(isSyncing).toBe(false); + }); + + it("clears isSyncing when revalidation completes (HTTP path)", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + const stalePicks = [makePick(1, "team-1", "player-1")]; + const freshPicks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + ]; + + if (freshPicks !== stalePicks) { + setIsSyncing(false); + } + + expect(isSyncing).toBe(false); + }); + + it("does not clear isSyncing when revalidation fails (staleness guard)", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + const stalePicks = [makePick(1, "team-1", "player-1")]; + const sameReference = stalePicks; + + if (sameReference !== stalePicks) { + setIsSyncing(false); + } + + expect(isSyncing).toBe(true); + }); + + describe("safety timeout", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it("clears isSyncing after 5 seconds even if sync never arrives", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + setIsSyncing(true); + const id = setTimeout(() => setIsSyncing(false), 5000); + + expect(isSyncing).toBe(true); + + vi.advanceTimersByTime(4999); + expect(isSyncing).toBe(true); + + vi.advanceTimersByTime(1); + expect(isSyncing).toBe(false); + + clearTimeout(id); + }); + + it("resets the safety timeout when reconnectCount increments rapidly", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + let timeoutId: ReturnType | null = null; + + const simulateReconnect = () => { + setIsSyncing(true); + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => setIsSyncing(false), 5000); + }; + + simulateReconnect(); + vi.advanceTimersByTime(3000); + expect(isSyncing).toBe(true); + + simulateReconnect(); + vi.advanceTimersByTime(3000); + expect(isSyncing).toBe(true); + + vi.advanceTimersByTime(2000); + expect(isSyncing).toBe(false); + + if (timeoutId) clearTimeout(timeoutId); + }); + + it("stays false when both socket and HTTP paths clear isSyncing (idempotency)", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + setIsSyncing(true); + expect(isSyncing).toBe(true); + + setIsSyncing(false); + expect(isSyncing).toBe(false); + + setIsSyncing(false); + expect(isSyncing).toBe(false); + }); + }); +}); diff --git a/app/utils/__tests__/team-names.test.ts b/app/utils/__tests__/team-names.test.ts new file mode 100644 index 0000000..157f4a5 --- /dev/null +++ b/app/utils/__tests__/team-names.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { + prependOwnerToTeamName, + stripOwnerFromTeamName, +} from "~/utils/team-names"; + +describe("prependOwnerToTeamName", () => { + it("prepends username with 's possessive", () => { + expect(prependOwnerToTeamName("Kind Cobras", "bob")).toBe( + "Bob's Kind Cobras" + ); + }); + + it("uses ' possessive when username ends in s", () => { + expect(prependOwnerToTeamName("Kind Cobras", "chris")).toBe( + "Chris' Kind Cobras" + ); + }); + + it("capitalizes first letter of username", () => { + expect(prependOwnerToTeamName("Furious Penguins", "christhrowsrock")).toBe( + "Christhrowsrock's Furious Penguins" + ); + }); + + it("handles username already capitalized", () => { + expect(prependOwnerToTeamName("Bold Bears", "Alice")).toBe( + "Alice's Bold Bears" + ); + }); + + it("handles name ending in multiple s characters", () => { + expect(prependOwnerToTeamName("Mighty Tigers", "jess")).toBe( + "Jess' Mighty Tigers" + ); + }); + + it("handles display name fallback", () => { + expect(prependOwnerToTeamName("Wild Wolves", "Member")).toBe( + "Member's Wild Wolves" + ); + }); + + it("handles single character username", () => { + expect(prependOwnerToTeamName("Team 1", "s")).toBe("S's Team 1"); + }); + + it("handles empty base name", () => { + expect(prependOwnerToTeamName("", "bob")).toBe("Bob's "); + }); +}); + +describe("stripOwnerFromTeamName", () => { + it("strips 's possessive prefix", () => { + expect(stripOwnerFromTeamName("Bob's Kind Cobras", "bob")).toBe( + "Kind Cobras" + ); + }); + + it("strips ' possessive prefix for names ending in s", () => { + expect(stripOwnerFromTeamName("Chris' Kind Cobras", "chris")).toBe( + "Kind Cobras" + ); + }); + + it("returns name unchanged if prefix does not match", () => { + expect( + stripOwnerFromTeamName("Alice's Awesome Team", "bob") + ).toBe("Alice's Awesome Team"); + }); + + it("handles title case mismatch in username", () => { + expect(stripOwnerFromTeamName("Bob's Team", "bob")).toBe("Team"); + }); + + it("returns name unchanged if renamed by owner", () => { + expect(stripOwnerFromTeamName("Custom Name", "bob")).toBe("Custom Name"); + }); + + it("handles username ending in s matching 's prefix", () => { + expect(stripOwnerFromTeamName("Jess' Wild Wolves", "jess")).toBe( + "Wild Wolves" + ); + }); +});