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 type { GroupStandingsRow } from "~/models/group-stage-match";
|
||||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||||
|
|
||||||
|
|
@ -38,6 +39,23 @@ function MatchResult({ match }: { match: GroupMatch }) {
|
||||||
const p1 = match.participant1?.name ?? "?";
|
const p1 = match.participant1?.name ?? "?";
|
||||||
const p2 = match.participant2?.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) {
|
if (match.isComplete && match.participant1Score !== null && match.participant2Score !== null) {
|
||||||
const s1 = match.participant1Score;
|
const s1 = match.participant1Score;
|
||||||
const s2 = match.participant2Score;
|
const s2 = match.participant2Score;
|
||||||
|
|
@ -61,7 +79,6 @@ function MatchResult({ match }: { match: GroupMatch }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<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="shrink-0 text-muted-foreground/60">vs</span>
|
||||||
<span className="truncate max-w-[80px]">{p2}</span>
|
<span className="truncate max-w-[80px]">{p2}</span>
|
||||||
</div>
|
</div>
|
||||||
{kickoff && (
|
{kickoffDisplay && (
|
||||||
<span className="text-[10px] text-muted-foreground/50">
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
{kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
|
{kickoffDisplay}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,7 +110,7 @@ export function GroupStageStandings({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Group Stage</h3>
|
<h3 className="text-lg font-semibold">Group Stage</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{groups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={group.groupName}
|
key={group.groupName}
|
||||||
className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden"
|
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;
|
if (!b.scheduledAt) return -1;
|
||||||
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
|
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[]>();
|
const byDay = new Map<string, GroupMatch[]>();
|
||||||
for (const m of sorted) {
|
for (const m of sorted) {
|
||||||
const key = m.scheduledAt
|
const key = m.scheduledAt ? m.scheduledAt.slice(0, 10) : "TBD";
|
||||||
? new Date(m.scheduledAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
||||||
: "TBD";
|
|
||||||
if (!byDay.has(key)) byDay.set(key, []);
|
if (!byDay.has(key)) byDay.set(key, []);
|
||||||
byDay.get(key)?.push(m);
|
byDay.get(key)?.push(m);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
|
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
|
||||||
{[...byDay.entries()].map(([day, matches]) => (
|
{[...byDay.entries()].map(([key, matches]) => {
|
||||||
<div key={day} className="space-y-0.5">
|
// Format from the UTC date string so label is identical on server and client.
|
||||||
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
|
const dayLabel = key === "TBD"
|
||||||
{day}
|
? "TBD"
|
||||||
</p>
|
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
|
||||||
{matches.map((m) => (
|
month: "short",
|
||||||
<MatchResult key={m.id} match={m} />
|
day: "numeric",
|
||||||
))}
|
timeZone: "UTC",
|
||||||
</div>
|
});
|
||||||
))}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
|
|
||||||
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
||||||
|
|
@ -45,6 +45,7 @@ export default function SportSeasonDetail({
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
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 hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0;
|
||||||
const showOtLosses = hasStandings && regularSeasonStandings.some((s) => s.otLosses !== null);
|
const showOtLosses = hasStandings && regularSeasonStandings.some((s) => s.otLosses !== null);
|
||||||
|
|
||||||
|
|
@ -75,13 +76,24 @@ export default function SportSeasonDetail({
|
||||||
|
|
||||||
const [view, setView] = useState<BracketView>(() => {
|
const [view, setView] = useState<BracketView>(() => {
|
||||||
if (showGroupStageToggle) {
|
if (showGroupStageToggle) {
|
||||||
return sportsSeason.status === "completed" ? "finished" : "playoffs";
|
if (sportsSeason.status === "completed") return "finished";
|
||||||
|
return bracketHasTeams ? "playoffs" : "groups";
|
||||||
}
|
}
|
||||||
if (!hasBracket) return "standings";
|
if (!hasBracket) return "standings";
|
||||||
if (sportsSeason.status === "completed") return "finished";
|
if (sportsSeason.status === "completed") return "finished";
|
||||||
return "playoffs";
|
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
|
const TOGGLE_VIEWS: { value: BracketView; label: string }[] = showGroupStageToggle
|
||||||
? [
|
? [
|
||||||
{ value: "groups", label: "Groups" },
|
{ value: "groups", label: "Groups" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue