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:
parent
d0998458c9
commit
3611f5620e
7 changed files with 277 additions and 4 deletions
|
|
@ -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({
|
|||
<h2 className="text-2xl font-bold mb-2">
|
||||
{connectionError
|
||||
? "Connection Error"
|
||||
: isSyncing
|
||||
? "Syncing Draft State..."
|
||||
: isReconnecting
|
||||
? "Reconnecting..."
|
||||
: "Connecting to Draft"}
|
||||
</h2>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-muted-foreground">
|
||||
{connectionError ? (
|
||||
connectionError
|
||||
) : isSyncing ? (
|
||||
"Reconnected. Syncing the latest draft state..."
|
||||
) : isReconnecting ? (
|
||||
"Lost connection to the draft server. Attempting to reconnect..."
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface UseDraftAuthRecoveryParams {
|
|||
setQueue: (queue: QueueItem[]) => void;
|
||||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => 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<Response | null> => {
|
||||
const response = await fetch(url, init);
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export function useDraftRoomState({
|
|||
const [isOvernightPause, setIsOvernightPause] = useState(false);
|
||||
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface UseDraftSocketEventsParams {
|
|||
setOvernightResumesAt: (value: Date | null) => void;
|
||||
setWatchedParticipantIds: (value: Set<string>) => 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[] }) => {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
85
app/utils/__tests__/team-names.test.ts
Normal file
85
app/utils/__tests__/team-names.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue