Show "Syncing Draft State..." overlay during draft reconnection (#361)

* Show "Syncing Draft State..." overlay during draft reconnection

When a user reconnects to a draft room (socket reconnect, tab visibility
return, etc.), there was a gap between when the socket connected and when
the full draft state sync arrived. The ConnectionOverlay vanished instantly
on reconnect, showing potentially stale data.

Add an isSyncing flag that keeps the overlay visible with "Syncing Draft
State..." messaging until the actual data arrives via socket or HTTP
revalidation. A 5-second safety timeout prevents the overlay from getting
stuck.

Fixes #78

* Add team names utility tests
This commit is contained in:
Chris Parsons 2026-04-30 20:33:00 -07:00 committed by GitHub
parent d0998458c9
commit 3611f5620e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 277 additions and 4 deletions

View file

@ -5,15 +5,16 @@ interface ConnectionOverlayProps {
isConnected: boolean; isConnected: boolean;
isReconnecting: boolean; isReconnecting: boolean;
connectionError: string | null; connectionError: string | null;
isSyncing: boolean;
} }
export function ConnectionOverlay({ export function ConnectionOverlay({
isConnected, isConnected,
isReconnecting, isReconnecting,
connectionError, connectionError,
isSyncing,
}: ConnectionOverlayProps) { }: ConnectionOverlayProps) {
// Don't show overlay if connected if (isConnected && !isSyncing) {
if (isConnected) {
return null; return null;
} }
@ -51,15 +52,18 @@ export function ConnectionOverlay({
<h2 className="text-2xl font-bold mb-2"> <h2 className="text-2xl font-bold mb-2">
{connectionError {connectionError
? "Connection Error" ? "Connection Error"
: isSyncing
? "Syncing Draft State..."
: isReconnecting : isReconnecting
? "Reconnecting..." ? "Reconnecting..."
: "Connecting to Draft"} : "Connecting to Draft"}
</h2> </h2>
{/* Message */}
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{connectionError ? ( {connectionError ? (
connectionError connectionError
) : isSyncing ? (
"Reconnected. Syncing the latest draft state..."
) : isReconnecting ? ( ) : isReconnecting ? (
"Lost connection to the draft server. Attempting to reconnect..." "Lost connection to the draft server. Attempting to reconnect..."
) : ( ) : (

View file

@ -31,6 +31,7 @@ interface UseDraftAuthRecoveryParams {
setQueue: (queue: QueueItem[]) => void; setQueue: (queue: QueueItem[]) => void;
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void; setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void; setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
setIsSyncing: (value: boolean) => void;
} }
@ -52,6 +53,7 @@ export function useDraftAuthRecovery({
setQueue, setQueue,
setTeamTimers, setTeamTimers,
setAutodraftStatus, setAutodraftStatus,
setIsSyncing,
}: UseDraftAuthRecoveryParams) { }: UseDraftAuthRecoveryParams) {
const [authDegraded, setAuthDegraded] = useState(false); const [authDegraded, setAuthDegraded] = useState(false);
@ -192,9 +194,10 @@ export function useDraftAuthRecovery({
}); });
return status; return status;
}); });
setIsSyncing(false);
} }
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId, }, [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<Response | null> => { const authFetch = useCallback(async (url: string, init?: RequestInit): Promise<Response | null> => {
const response = await fetch(url, init); const response = await fetch(url, init);

View file

@ -136,6 +136,7 @@ export function useDraftRoomState({
const [isOvernightPause, setIsOvernightPause] = useState(false); const [isOvernightPause, setIsOvernightPause] = useState(false);
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null); const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null);
const [roomClosed, setRoomClosed] = useState(false); const [roomClosed, setRoomClosed] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
return { return {
picks, setPicks, picks, setPicks,
@ -177,5 +178,6 @@ export function useDraftRoomState({
isOvernightPause, setIsOvernightPause, isOvernightPause, setIsOvernightPause,
overnightResumesAt, setOvernightResumesAt, overnightResumesAt, setOvernightResumesAt,
roomClosed, setRoomClosed, roomClosed, setRoomClosed,
isSyncing, setIsSyncing,
}; };
} }

View file

@ -40,6 +40,7 @@ interface UseDraftSocketEventsParams {
setOvernightResumesAt: (value: Date | null) => void; setOvernightResumesAt: (value: Date | null) => void;
setWatchedParticipantIds: (value: Set<string>) => void; setWatchedParticipantIds: (value: Set<string>) => void;
setRoomClosed: (value: boolean) => void; setRoomClosed: (value: boolean) => void;
setIsSyncing: (value: boolean) => void;
} }
export function useDraftSocketEvents({ export function useDraftSocketEvents({
@ -68,6 +69,7 @@ export function useDraftSocketEvents({
setOvernightResumesAt, setOvernightResumesAt,
setWatchedParticipantIds, setWatchedParticipantIds,
setRoomClosed, setRoomClosed,
setIsSyncing,
}: UseDraftSocketEventsParams) { }: UseDraftSocketEventsParams) {
useEffect(() => { useEffect(() => {
type PickMadePayload = { type PickMadePayload = {
@ -225,6 +227,7 @@ export function useDraftSocketEvents({
setWatchedParticipantIds(new Set(data.watchlistParticipantIds)); setWatchedParticipantIds(new Set(data.watchlistParticipantIds));
} }
} }
setIsSyncing(false);
}; };
const handleWatchlistUpdated = (data: { participantIds: string[] }) => { const handleWatchlistUpdated = (data: { participantIds: string[] }) => {

View file

@ -254,6 +254,7 @@ export default function DraftRoom() {
isOvernightPause, setIsOvernightPause, isOvernightPause, setIsOvernightPause,
overnightResumesAt, setOvernightResumesAt, overnightResumesAt, setOvernightResumesAt,
roomClosed, setRoomClosed, roomClosed, setRoomClosed,
isSyncing, setIsSyncing,
} = useDraftRoomState({ } = useDraftRoomState({
draftPicks, draftPicks,
season, season,
@ -335,8 +336,15 @@ export default function DraftRoom() {
setQueue, setQueue,
setTeamTimers, setTeamTimers,
setAutodraftStatus, 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 // Shared transforms for eligibility calculations
const transformedPicks = useMemo( const transformedPicks = useMemo(
@ -463,6 +471,7 @@ export default function DraftRoom() {
setOvernightResumesAt, setOvernightResumesAt,
setWatchedParticipantIds, setWatchedParticipantIds,
setRoomClosed, setRoomClosed,
setIsSyncing,
}); });
// Persist sidebar collapsed state // Persist sidebar collapsed state
@ -1660,6 +1669,7 @@ export default function DraftRoom() {
isConnected={isConnected} isConnected={isConnected}
isReconnecting={isReconnecting} isReconnecting={isReconnecting}
connectionError={connectionError} connectionError={connectionError}
isSyncing={isSyncing}
/> />
{/* Auth Recovery Overlay - blocks interaction when session expires */} {/* Auth Recovery Overlay - blocks interaction when session expires */}

View file

@ -760,3 +760,169 @@ describe("autodraft and timer sync on revalidation completion", () => {
expect(teamTimers["team-3"]).toBe(120); 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<typeof setTimeout> | 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);
});
});
});

View file

@ -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"
);
});
});