claude/inspiring-turing-9w84hu #94

Merged
chrisp merged 2 commits from claude/inspiring-turing-9w84hu into main 2026-06-17 04:29:24 +00:00
2 changed files with 58 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 && (
{kickoffDisplay && (
<span className="text-[10px] text-muted-foreground/50">
{kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
{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">
{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";
@ -45,6 +45,7 @@ export default function SportSeasonDetail({
} = loaderData;
const hasBracket = playoffMatches && playoffMatches.length > 0;
const bracketHasTeams = hasBracket && playoffMatches.some((m) => m.participant1 !== null || m.participant2 !== null);
const hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0;
const showOtLosses = hasStandings && regularSeasonStandings.some((s) => s.otLosses !== null);
@ -75,13 +76,24 @@ export default function SportSeasonDetail({
const [view, setView] = useState<BracketView>(() => {
if (showGroupStageToggle) {
return sportsSeason.status === "completed" ? "finished" : "playoffs";
if (sportsSeason.status === "completed") return "finished";
return bracketHasTeams ? "playoffs" : "groups";
}
if (!hasBracket) return "standings";
if (sportsSeason.status === "completed") return "finished";
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" },