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