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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Bo6Rsmg8FosYcDJsg14tX
This commit is contained in:
parent
5db574263f
commit
4bede9013a
2 changed files with 56 additions and 19 deletions
|
|
@ -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>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue