From 4bede9013a16aba4ce0687645ff4dfd8ec517fb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 03:25:32 +0000 Subject: [PATCH] Fix four code review findings in group stage view 1. Stable byDay map key: use UTC date string (scheduledAt.slice(0,10)) instead of toLocaleDateString so server and client always produce the same Map structure, eliminating the key={day} hydration tree mismatch. 2. Correct time localization: render UTC time as the stable SSR value, then replace with the user's local time in useEffect after hydration. Removes suppressHydrationWarning from both elements. 3. Fix groups vs visibleGroups: the JSX was iterating the unfiltered groups array, making the showEmpty filter a no-op. Now uses visibleGroups.map so groups with no activity are properly hidden. 4. Sync view after revalidation: add useEffect that advances the tab from "groups" to "playoffs" when bracketHasTeams flips true during an active session, without overriding a deliberate manual selection. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_011Bo6Rsmg8FosYcDJsg14tX --- .../sport-season/GroupStageStandings.tsx | 63 +++++++++++++------ ...eagueId.sports-seasons.$sportsSeasonId.tsx | 12 +++- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/app/components/sport-season/GroupStageStandings.tsx b/app/components/sport-season/GroupStageStandings.tsx index 360b0c0..74733e8 100644 --- a/app/components/sport-season/GroupStageStandings.tsx +++ b/app/components/sport-season/GroupStageStandings.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from "react"; import type { GroupStandingsRow } from "~/models/group-stage-match"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; @@ -38,6 +39,23 @@ function MatchResult({ match }: { match: GroupMatch }) { const p1 = match.participant1?.name ?? "?"; const p2 = match.participant2?.name ?? "?"; + // SSR renders UTC time (stable); useEffect replaces with the user's local time after hydration. + const [kickoffDisplay, setKickoffDisplay] = useState(() => + match.scheduledAt + ? new Date(match.scheduledAt).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }) + : null + ); + useEffect(() => { + if (!match.scheduledAt) return; + setKickoffDisplay( + new Date(match.scheduledAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + ); + }, [match.scheduledAt]); + if (match.isComplete && match.participant1Score !== null && match.participant2Score !== null) { const s1 = match.participant1Score; const s2 = match.participant2Score; @@ -61,7 +79,6 @@ function MatchResult({ match }: { match: GroupMatch }) { ); } - const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null; return (
@@ -69,9 +86,9 @@ function MatchResult({ match }: { match: GroupMatch }) { vs {p2}
- {kickoff && ( - - {kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })} + {kickoffDisplay && ( + + {kickoffDisplay} )}
@@ -93,7 +110,7 @@ export function GroupStageStandings({

Group Stage

- {groups.map((group) => ( + {visibleGroups.map((group) => (
(); for (const m of sorted) { - const key = m.scheduledAt - ? new Date(m.scheduledAt).toLocaleDateString("en-US", { month: "short", day: "numeric" }) - : "TBD"; + const key = m.scheduledAt ? m.scheduledAt.slice(0, 10) : "TBD"; if (!byDay.has(key)) byDay.set(key, []); byDay.get(key)?.push(m); } return (
- {[...byDay.entries()].map(([day, matches]) => ( -
-

- {day} -

- {matches.map((m) => ( - - ))} -
- ))} + {[...byDay.entries()].map(([key, matches]) => { + // Format from the UTC date string so label is identical on server and client. + const dayLabel = key === "TBD" + ? "TBD" + : new Date(key + "T12:00:00Z").toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }); + return ( +
+

+ {dayLabel} +

+ {matches.map((m) => ( + + ))} +
+ ); + })}
); })()} diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index d69355e..2164b83 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId"; import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server"; @@ -84,6 +84,16 @@ export default function SportSeasonDetail({ return "playoffs"; }); + // When the loader revalidates and bracket teams are seeded, promote the default tab + // from "groups" to "playoffs". Only fires if the view is still at the automatic default + // ("groups"), so a user who manually clicked Groups from Bracket view is unaffected. + useEffect(() => { + if (!showGroupStageToggle || sportsSeason.status === "completed") return; + if (bracketHasTeams) { + setView((v) => (v === "groups" ? "playoffs" : v)); + } + }, [bracketHasTeams, showGroupStageToggle, sportsSeason.status]); + const TOGGLE_VIEWS: { value: BracketView; label: string }[] = showGroupStageToggle ? [ { value: "groups", label: "Groups" },