Fix four code review findings in group stage view
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m41s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Bo6Rsmg8FosYcDJsg14tX
This commit is contained in:
Claude 2026-06-17 03:25:32 +00:00
parent 5db574263f
commit 4bede9013a
No known key found for this signature in database
2 changed files with 56 additions and 19 deletions

View file

@ -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 (
<div className="flex flex-col gap-0">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@ -69,9 +86,9 @@ function MatchResult({ match }: { match: GroupMatch }) {
<span className="shrink-0 text-muted-foreground/60">vs</span>
<span className="truncate max-w-[80px]">{p2}</span>
</div>
{kickoff && (
<span className="text-[10px] text-muted-foreground/50" suppressHydrationWarning>
{kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
{kickoffDisplay && (
<span className="text-[10px] text-muted-foreground/50">
{kickoffDisplay}
</span>
)}
</div>
@ -93,7 +110,7 @@ export function GroupStageStandings({
<div className="space-y-4">
<h3 className="text-lg font-semibold">Group Stage</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{groups.map((group) => (
{visibleGroups.map((group) => (
<div
key={group.groupName}
className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden"
@ -169,26 +186,36 @@ export function GroupStageStandings({
if (!b.scheduledAt) return -1;
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
});
// Use the UTC date portion as a stable key so server and client always
// produce the same Map structure regardless of the user's timezone.
const byDay = new Map<string, GroupMatch[]>();
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 (
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
{[...byDay.entries()].map(([day, matches]) => (
<div key={day} className="space-y-0.5">
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1" suppressHydrationWarning>
{day}
</p>
{matches.map((m) => (
<MatchResult key={m.id} match={m} />
))}
</div>
))}
{[...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 (
<div key={key} className="space-y-0.5">
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
{dayLabel}
</p>
{matches.map((m) => (
<MatchResult key={m.id} match={m} />
))}
</div>
);
})}
</div>
);
})()}

View file

@ -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" },