claude/inspiring-turing-9w84hu #94
2 changed files with 58 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 && (
|
||||
{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>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue