Fix standings table bugs and polish across all three scoring patterns (#415)

* Fix standings table bugs and polish across all three scoring patterns

- Fix isLastBeforePointsLine/PlayoffLine: was incorrectly suppressing the
  bottom border on the last row of a section when no divider followed
  (nextStanding === undefined case). Now correctly requires nextStanding
  to exist and exceed the cutoff rank.
- Fix totalCols colSpan overcounting: hidden sm:table-cell columns
  (Drafted By / Mgr) don't occupy column slots on mobile, so counting
  them caused the divider rows to span one too many. Replaced with
  colSpan={100} (browser caps to actual column count).
- Move pointsLinePushed mutation out of render in SeasonStandings and
  QualifyingPointsStandings: replaced let+mutation+array-push pattern
  with pre-computed firstOver8Idx and React.Fragment per row.
- Replace array-returning .map() with keyed React.Fragment in both files.
- Remove unused description prop from SeasonStandingsProps.
- Use useId() for Switch id props in all three components to prevent
  id collisions when mounted multiple times.
- Fix formatQP NaN fallback from "0" to "—".
- Add comment noting canFinalize guards the admin-only finalize UI.
- Drop dead description prop pass-through in SportSeasonDisplay.

Fixes #408

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix RegularSeasonStandings tests broken by showStats toggle

- STK/streak tests: click the Details switch before asserting on stats
  columns, which are now hidden behind the toggle by default
- Ownership badge test: use getAllByText since the badge renders in both
  the mobile inline slot and the hidden-sm desktop Mgr cell
- Division label test: remove expectation for inline per-row division
  labels, which were intentionally removed to fix mobile scroll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-12 10:39:25 -07:00 committed by GitHub
parent c5366fe9cc
commit 4f111820ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 321 additions and 389 deletions

View file

@ -1,3 +1,4 @@
import { Fragment } from "react";
import { Form } from "react-router";
import { Button } from "~/components/ui/button";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
@ -8,14 +9,6 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Trophy, CheckCircle2 } from "lucide-react";
interface TeamOwnership {
@ -53,8 +46,15 @@ interface QualifyingPointsStandingsProps {
isFinalized: boolean;
totalMajors?: number | null;
majorsCompleted?: number;
canFinalize: boolean; // Whether all majors are complete
canFinalize: boolean;
teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[];
}
function formatQP(raw: string): string {
const n = parseFloat(raw);
if (isNaN(n)) return "—";
return n % 1 === 0 ? n.toString() : n.toFixed(1);
}
export function QualifyingPointsStandings({
@ -65,23 +65,13 @@ export function QualifyingPointsStandings({
majorsCompleted = 0,
canFinalize,
teamOwnerships = [],
userParticipantIds = [],
}: QualifyingPointsStandingsProps) {
const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o]));
const pointsMap: Record<number, number> = scoringRules
? {
1: scoringRules.pointsFor1st,
2: scoringRules.pointsFor2nd,
3: scoringRules.pointsFor3rd,
4: scoringRules.pointsFor4th,
5: scoringRules.pointsFor5th,
6: scoringRules.pointsFor6th,
7: scoringRules.pointsFor7th,
8: scoringRules.pointsFor8th,
}
: {};
const userParticipantSet = new Set(userParticipantIds);
// Assign current ranks with tie handling
const rankedStandings: Array<typeof standings[0] & { currentRank: number }> = [];
const rankedStandings: Array<QPStanding & { currentRank: number }> = [];
let previousQP = -1;
let previousRankStart = -1;
@ -97,32 +87,24 @@ export function QualifyingPointsStandings({
}
rankedStandings.push({ ...standing, currentRank });
previousRankStart = currentRank;
previousQP = currentQP;
}
// Pre-compute tied counts per rank to avoid O(N²) scanning per row
const tiedCountByRank = new Map<number, number>();
for (const s of rankedStandings) {
tiedCountByRank.set(s.currentRank, (tiedCountByRank.get(s.currentRank) ?? 0) + 1);
}
// Calculate EV for a rank by averaging points across all tied positions
const calculateEV = (currentRank: number): number => {
if (!scoringRules || currentRank > 8) return 0;
const tiedCount = tiedCountByRank.get(currentRank) ?? 1;
let total = 0;
for (let i = 0; i < tiedCount; i++) {
total += pointsMap[currentRank + i] ?? 0;
}
return total / tiedCount;
};
const showOwnership = teamOwnerships.length > 0;
// Index of first row with rank > 8; -1 when there is no cutoff.
const firstOver8Idx = rankedStandings.findIndex((s) => s.currentRank > 8);
return (
<Card className={isFinalized ? "border-emerald-500/30" : "border-amber-accent/30"}>
<Card>
<CardHeader>
<CardTitle className={isFinalized ? "text-emerald-400" : "text-amber-accent"}>
<CardTitle>
<Trophy className="inline mr-2 h-5 w-5" />
Qualifying Points Standings
</CardTitle>
@ -130,20 +112,12 @@ export function QualifyingPointsStandings({
{isFinalized ? (
<span className="text-emerald-400 font-semibold">
<CheckCircle2 className="inline h-4 w-4 mr-1" />
Finalized - Fantasy points have been assigned
Finalized fantasy points have been assigned
</span>
) : (
<>
Current standings based on {majorsCompleted} of {totalMajors || '?'} major tournaments completed.
{scoringRules ? (
<span className="block mt-1">
Projected points account for ties tied participants share the available points equally.
</span>
) : (
<span className="block mt-1 text-muted-foreground">
Projected points will be shown when linked to a fantasy season.
</span>
)}
Current standings based on {majorsCompleted} of {totalMajors || "?"} major
tournaments completed.
</>
)}
</CardDescription>
@ -155,94 +129,98 @@ export function QualifyingPointsStandings({
</p>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Rank</TableHead>
<TableHead>Participant</TableHead>
{teamOwnerships.length > 0 && (
<TableHead className="w-40">Owner</TableHead>
<div className="overflow-x-auto -mx-6 px-6">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground uppercase tracking-wide border-b">
<th className="text-left py-1.5 pl-2 pr-2 w-10">#</th>
<th className="text-left py-1.5">Participant</th>
<th className="text-right py-1.5 px-2 w-24">Total QP</th>
{showOwnership && (
<th className="hidden sm:table-cell text-right py-1.5 pl-4 w-40">Drafted By</th>
)}
<TableHead className="w-28 text-right">Total QP</TableHead>
<TableHead className="w-24 text-right">Events</TableHead>
{scoringRules && !isFinalized && (
<TableHead className="w-32 text-right">Projected Points</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{rankedStandings.map((standing) => {
const projectedPoints = calculateEV(standing.currentRank);
</tr>
</thead>
<tbody>
{rankedStandings.map((standing, idx) => {
const ownership = showOwnership
? ownershipMap.get(standing.participant.id)
: undefined;
const isTop8 = standing.currentRank <= 8;
const isTied = (tiedCountByRank.get(standing.currentRank) ?? 1) > 1;
const isOwned = userParticipantSet.has(standing.participant.id);
const showPointsLineBefore = firstOver8Idx !== -1 && idx === firstOver8Idx;
const isLastBeforePointsLine = firstOver8Idx !== -1 && idx === firstOver8Idx - 1;
return (
<TableRow key={standing.id} className={!isTop8 ? "opacity-60" : ""}>
<TableCell>
<div className="flex items-center gap-2">
{standing.currentRank === 1 && !isTied && <span className="text-xl">🥇</span>}
{standing.currentRank === 2 && !isTied && <span className="text-xl">🥈</span>}
{standing.currentRank === 3 && !isTied && <span className="text-xl">🥉</span>}
<span className="font-semibold">
{isTied && "T"}
{standing.currentRank}
{standing.currentRank === 1
? "st"
: standing.currentRank === 2
? "nd"
: standing.currentRank === 3
? "rd"
: "th"}
<Fragment key={standing.id}>
{showPointsLineBefore && (
<tr>
<td colSpan={100} className="py-0.5">
<div className="flex w-full items-center gap-2">
<div className="flex-1 border-t border-dashed border-amber-500/40" />
<span className="text-[10px] text-amber-600/70 dark:text-amber-500/70 uppercase tracking-wide whitespace-nowrap font-medium px-1">
Points Line
</span>
<div className="flex-1 border-t border-dashed border-amber-500/40" />
</div>
</TableCell>
<TableCell className="font-medium">
</td>
</tr>
)}
<tr
className={`${isLastBeforePointsLine ? "" : "border-b border-border/50 last:border-0"} ${
isOwned
? "bg-primary/5"
: isTop8
? "hover:bg-muted/30"
: "opacity-60 hover:bg-muted/30"
}`}
>
<td className="py-2 pl-2 pr-2 text-muted-foreground tabular-nums">
{isTied ? `T${standing.currentRank}` : standing.currentRank}
</td>
<td className="py-2 font-medium">
<span className={isOwned ? "text-primary" : ""}>
{standing.participant.name}
</TableCell>
{teamOwnerships.length > 0 && (
<TableCell>
{(() => {
const ownership = ownershipMap.get(standing.participant.id);
return ownership ? (
</span>
{ownership && (
<div className="sm:hidden mt-0.5">
<TeamOwnerBadge
teamName={ownership.teamName}
ownerName={ownership.ownerName}
/>
) : (
<span className="text-xs text-muted-foreground">Undrafted</span>
);
})()}
</TableCell>
</div>
)}
<TableCell className="text-right">
<span className="font-semibold text-amber-accent">
{parseFloat(standing.totalQualifyingPoints).toFixed(2)} QP
</span>
</TableCell>
<TableCell className="text-right text-muted-foreground">
{standing.eventsScored}
</TableCell>
{scoringRules && !isFinalized && (
<TableCell className="text-right">
{isTop8 ? (
<span className="font-semibold text-emerald-400">
{projectedPoints % 1 === 0
? projectedPoints
: projectedPoints.toFixed(1)}{" "}
pts
</td>
<td className="py-2 px-2 text-right tabular-nums">
<span className={`font-semibold ${isTop8 ? "text-amber-accent" : "text-muted-foreground"}`}>
{formatQP(standing.totalQualifyingPoints)} QP
</span>
</td>
{showOwnership && (
<td className="hidden sm:table-cell py-2 pl-4 text-right">
{ownership ? (
<div className="flex justify-end">
<TeamOwnerBadge
teamName={ownership.teamName}
ownerName={ownership.ownerName}
align="right"
/>
</div>
) : (
<span className="text-muted-foreground">0 pts</span>
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</td>
)}
</TableRow>
</tr>
</Fragment>
);
})}
</TableBody>
</Table>
</tbody>
</table>
</div>
{/* canFinalize is controlled by the route loader; this section is only shown to league admins */}
{!isFinalized && canFinalize && (
<div className="mt-6 p-4 border-t">
<div className="space-y-3">
@ -252,7 +230,7 @@ export function QualifyingPointsStandings({
All {totalMajors} major tournaments are complete. Finalizing will:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mt-2 space-y-1">
<li>Convert the top 8 participants to fantasy placements 1-8</li>
<li>Convert the top 8 participants to fantasy placements 18</li>
<li>Award fantasy points based on league scoring rules</li>
<li>Update all league standings</li>
<li>Lock the qualifying points (no further changes)</li>
@ -264,7 +242,11 @@ export function QualifyingPointsStandings({
type="submit"
className="bg-amber-600 hover:bg-amber-700"
onClick={(e) => {
if (!confirm("Are you sure you want to finalize qualifying points? This action cannot be undone.")) {
if (
!confirm(
"Are you sure you want to finalize qualifying points? This action cannot be undone."
)
) {
e.preventDefault();
}
}}
@ -277,11 +259,12 @@ export function QualifyingPointsStandings({
</div>
)}
{!isFinalized && !canFinalize && (
{!isFinalized && !canFinalize && scoringRules && (
<div className="mt-6 p-4 border-t">
<p className="text-sm text-muted-foreground">
<span className="font-semibold">Not ready to finalize yet.</span> Complete all {totalMajors} major tournaments before finalizing qualifying points.
({majorsCompleted} of {totalMajors} completed)
<span className="font-semibold">Not ready to finalize yet.</span> Complete all{" "}
{totalMajors} major tournaments before finalizing qualifying points. (
{majorsCompleted} of {totalMajors} completed)
</p>
</div>
)}

View file

@ -1,3 +1,4 @@
import { Fragment, useId, useState } from "react";
import {
Card,
CardContent,
@ -5,23 +6,15 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2, Star } from "lucide-react";
import { Switch } from "~/components/ui/switch";
import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2 } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
interface SeasonStanding {
id: string;
championshipPoints: string; // Decimal as string
championshipPoints: string;
position: number;
previousPosition?: number | null; // For showing movement
previousPosition?: number | null;
participant: {
id: string;
name: string;
@ -37,31 +30,20 @@ interface TeamOwnership {
interface SeasonStandingsProps {
standings: SeasonStanding[];
teamOwnerships?: TeamOwnership[]; // Which teams own which participants
userParticipantIds?: string[]; // Participants drafted by the current user
teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[];
showOwnership?: boolean;
isFinalized?: boolean; // Whether season is complete
isFinalized?: boolean;
title?: string;
description?: string;
}
/**
* SeasonStandings component - Displays F1-style championship standings
*
* Features:
* - Shows participants ranked by championship points
* - Positions auto-calculated from points (highest = 1st)
* - Optional ownership hints with team avatars
* - Movement indicators (position changes)
* - Handles ties (same points = same position)
*/
function getMovementIndicator(
currentPosition: number,
previousPosition?: number | null
) {
if (!previousPosition) return null;
const change = previousPosition - currentPosition; // Positive means moved up
const change = previousPosition - currentPosition;
if (change > 0) {
return (
@ -86,12 +68,6 @@ function getMovementIndicator(
}
}
function getPositionBadge(position: number, isTied: boolean) {
const suffix = position === 1 ? "st" : position === 2 ? "nd" : position === 3 ? "rd" : "th";
const positionText = isTied ? `T${position}` : `${position}${suffix}`;
return <span className="font-medium">{positionText}</span>;
}
export function SeasonStandings({
standings,
teamOwnerships = [],
@ -99,48 +75,27 @@ export function SeasonStandings({
showOwnership = true,
isFinalized = false,
title = "Championship Standings",
description: _description,
}: SeasonStandingsProps) {
const switchId = useId();
const [showStats, setShowStats] = useState(false);
const userParticipantSet = new Set(userParticipantIds);
// Create ownership map for fast lookup
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((ownership) => {
ownershipMap.set(ownership.participantId, ownership);
});
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
// Get ownership info for a participant
const getOwnership = (participantId: string): TeamOwnership | null => {
if (!showOwnership) return null;
return ownershipMap.get(participantId) || null;
};
const checkIfTied = (standing: SeasonStanding): boolean =>
standings.some((o) => o.id !== standing.id && o.position === standing.position);
// Check if multiple participants share the same position
const checkIfTied = (standing: SeasonStanding): boolean => {
return standings.some(
(other) =>
other.id !== standing.id && other.position === standing.position
);
};
const hasChangeColumn = standings.some((s) => s.previousPosition);
const sorted = [...standings].toSorted((a, b) => a.position - b.position);
// Count top 8 finishers (those who will get fantasy points)
const top8Count = standings.filter((s) => s.position <= 8).length;
// Index of first row with position > 8; -1 when there is no cutoff.
const firstOver8Idx = sorted.findIndex((s) => s.position > 8);
return (
<Card
className={
isFinalized
? "border-emerald-500/30"
: "border-electric/30"
}
>
<Card>
<CardHeader>
<CardTitle
className={
isFinalized
? "text-emerald-400"
: "text-electric"
}
>
<CardTitle>
<Flag className="inline mr-2 h-5 w-5" />
{title}
</CardTitle>
@ -148,17 +103,11 @@ export function SeasonStandings({
{isFinalized ? (
<span className="text-emerald-400 font-semibold">
<CheckCircle2 className="inline h-4 w-4 mr-1" />
Season complete - Fantasy points assigned to top 8 finishers
Season complete fantasy points assigned to top 8 finishers
</span>
) : (
<>
Current championship standings. Positions calculated from points
(highest = 1st).
{top8Count > 0 && (
<span className="block mt-1">
Top 8 finishers will receive fantasy points when season completes.
</span>
)}
Current championship standings. Positions calculated from points (highest = 1st).
</>
)}
</CardDescription>
@ -173,104 +122,117 @@ export function SeasonStandings({
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Pos</TableHead>
{standings.some((s) => s.previousPosition) && (
<TableHead className="w-20">Change</TableHead>
{/* Details toggle only shown when position-change data is available */}
{hasChangeColumn && (
<div className="flex items-center justify-end gap-2 mb-2">
<label className="text-xs text-muted-foreground cursor-pointer" htmlFor={switchId}>
Details
</label>
<Switch
id={switchId}
checked={showStats}
onCheckedChange={setShowStats}
/>
</div>
)}
<TableHead>Participant</TableHead>
<TableHead className="text-right w-20">Points</TableHead>
<div className="overflow-x-auto -mx-6 px-6">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground uppercase tracking-wide border-b">
<th className="text-left py-1.5 pl-2 pr-2 w-10">#</th>
{hasChangeColumn && showStats && (
<th className="text-left py-1.5 w-16">Change</th>
)}
<th className="text-left py-1.5">Participant</th>
<th className="text-right py-1.5 px-2 w-20">Points</th>
{showOwnership && (
<TableHead className="w-40 pl-6">Drafted By</TableHead>
<th className="hidden sm:table-cell text-right py-1.5 pl-4 w-40">Drafted By</th>
)}
</TableRow>
</TableHeader>
<TableBody>
{standings
.toSorted((a, b) => a.position - b.position)
.map((standing) => {
const ownership = getOwnership(standing.participant.id);
</tr>
</thead>
<tbody>
{sorted.map((standing, idx) => {
const ownership = showOwnership
? ownershipMap.get(standing.participant.id) ?? null
: null;
const isTied = checkIfTied(standing);
const isTop8 = standing.position <= 8;
const isOwned = userParticipantSet.has(standing.participant.id);
let rowClass = "";
if (isOwned && isTop8) {
rowClass = "bg-electric/8 border-l-2 border-l-electric font-medium";
} else if (isOwned && !isTop8) {
rowClass = "bg-muted/20 border-l-2 border-l-muted-foreground/40 opacity-80";
} else if (isTop8) {
rowClass = standing.position <= 3 ? "bg-muted/30 font-medium" : "";
} else {
rowClass = "opacity-60";
}
const isTop8 = standing.position <= 8;
const showPointsLineBefore = firstOver8Idx !== -1 && idx === firstOver8Idx;
const isLastBeforePointsLine = firstOver8Idx !== -1 && idx === firstOver8Idx - 1;
return (
<TableRow
key={standing.id}
className={rowClass}
>
<TableCell>
{getPositionBadge(standing.position, isTied)}
</TableCell>
{standings.some((s) => s.previousPosition) && (
<TableCell>
{getMovementIndicator(
standing.position,
standing.previousPosition
<Fragment key={standing.id}>
{showPointsLineBefore && (
<tr>
<td colSpan={100} className="py-0.5">
<div className="flex w-full items-center gap-2">
<div className="flex-1 border-t border-dashed border-amber-500/40" />
<span className="text-[10px] text-amber-600/70 dark:text-amber-500/70 uppercase tracking-wide whitespace-nowrap font-medium px-1">
Points Line
</span>
<div className="flex-1 border-t border-dashed border-amber-500/40" />
</div>
</td>
</tr>
)}
</TableCell>
)}
<TableCell className="font-medium">
<span>{standing.participant.name}</span>
{isOwned && (
<Star
className={`inline ml-1.5 h-3 w-3 fill-current ${isTop8 ? "text-electric" : "text-muted-foreground"}`}
/>
)}
</TableCell>
<TableCell className="text-right">
<span
className={`font-semibold ${
isTop8
? "text-electric"
: "text-muted-foreground"
<tr
className={`${isLastBeforePointsLine ? "" : "border-b border-border/50 last:border-0"} ${
isOwned ? "bg-primary/5" : isTop8 ? "hover:bg-muted/30" : "opacity-60 hover:bg-muted/30"
}`}
>
{Math.round(parseFloat(standing.championshipPoints))}
<td className="py-2 pl-2 pr-2 text-muted-foreground tabular-nums">
{isTied ? `T${standing.position}` : standing.position}
</td>
{hasChangeColumn && showStats && (
<td className="py-2">
{getMovementIndicator(standing.position, standing.previousPosition)}
</td>
)}
<td className="py-2 font-medium">
<span className={isOwned ? "text-primary" : ""}>
{standing.participant.name}
</span>
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{ownership ? (
{ownership && (
<div className="sm:hidden mt-0.5">
<TeamOwnerBadge
teamName={ownership.teamName}
ownerName={ownership.ownerName}
/>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
{!isFinalized && top8Count > 0 && (
<div className="mt-4 p-3 border-t">
<p className="text-sm text-muted-foreground">
<Badge variant="secondary" className="text-xs">
{top8Count}
</Badge>{" "}
participant{top8Count !== 1 ? "s" : ""} currently in top 8 will
receive fantasy points when season completes.
</p>
</div>
)}
</td>
<td className="py-2 px-2 text-right tabular-nums">
<span
className={`font-semibold ${
isTop8 ? "text-electric" : "text-muted-foreground"
}`}
>
{Math.round(parseFloat(standing.championshipPoints))}
</span>
</td>
{showOwnership && (
<td className="hidden sm:table-cell py-2 pl-4 text-right">
{ownership ? (
<div className="flex justify-end">
<TeamOwnerBadge
teamName={ownership.teamName}
ownerName={ownership.ownerName}
align="right"
/>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
)}
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
</>
)}
</CardContent>

View file

@ -173,7 +173,6 @@ export function SportSeasonDisplay({
showOwnership={showOwnership}
isFinalized={seasonIsFinalized}
title={sportSeasonName}
description="Championship standings - positions calculated from points"
/>
);
@ -188,6 +187,7 @@ export function SportSeasonDisplay({
majorsCompleted={majorsCompleted}
canFinalize={canFinalize}
teamOwnerships={showOwnership ? teamOwnerships : []}
userParticipantIds={userParticipantIds}
/>
);

View file

@ -1,3 +1,4 @@
import { useId, useState } from "react";
import {
Card,
CardContent,
@ -5,6 +6,7 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Switch } from "~/components/ui/switch";
import { BarChart3 } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
@ -229,38 +231,31 @@ function StandingsTable({
userParticipantIds,
showOtLosses,
showSoccerTable = false,
showStats,
}: {
sections: TableSection[];
teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[];
showOtLosses: boolean;
showSoccerTable?: boolean;
showStats: boolean;
}) {
const allRows = sections.flatMap((s) => s.rows);
const hasStreak = allRows.some((r) => r.streak !== null);
const hasLastTen = allRows.some((r) => r.lastTen !== null);
const hasGB = allRows.some((r) => r.gamesBack !== null);
// # Team GP W [D] L [GF GA GD PTS] [OTL PTS] [PCT] [GB] [L10] [STK] Mgr
// Soccer: 11 fixed columns. Non-soccer: 5 base + GP + optional cols.
// GP and PCT are always counted even when hidden on mobile; colSpan must
// reflect all rendered columns regardless of CSS visibility.
const totalCols = showSoccerTable
? 11
: 5 + 1 /* GP */ + (showOtLosses ? 2 : 0) + 1 /* PCT */ + (hasGB ? 1 : 0) + (hasLastTen ? 1 : 0) + (hasStreak ? 1 : 0);
// Non-soccer rows get a secondary sub-row on mobile showing GP/PCT/GB/L10.
// Soccer tables show GP inline (no sub-row) since they already show many columns.
const showSubRow = !showSoccerTable;
// # Team [GP] W [D] L [GF GA GD PTS] [OTL PTS] [PCT] [GB] [L10] [STK] Mgr
// Stats columns (GP, PCT, GB, L10, STK) are toggled by showStats.
return (
<div className="overflow-x-auto -mx-6 px-6">
<table className="w-full min-w-[360px] text-sm">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground uppercase tracking-wide border-b">
<th className="text-left py-1.5 pr-2 w-6">#</th>
<th className="text-left py-1.5 pl-2 pr-2 w-8">#</th>
<th className="text-left py-1.5">Team</th>
<th className={`${showSubRow ? "hidden sm:table-cell " : ""}text-right py-1.5 px-2 w-10`}>GP</th>
{(!showSoccerTable && showStats) && <th className="text-right py-1.5 px-2 w-10">GP</th>}
<th className="text-right py-1.5 px-2 w-8">W</th>
{showSoccerTable && <th className="text-right py-1.5 px-2 w-8">D</th>}
<th className="text-right py-1.5 px-2 w-8">L</th>
@ -270,11 +265,11 @@ function StandingsTable({
{showSoccerTable && <th className="text-right py-1.5 px-2 w-10">PTS</th>}
{showOtLosses && <th className="text-right py-1.5 px-2 w-10">OTL</th>}
{showOtLosses && <th className="text-right py-1.5 px-2 w-10">PTS</th>}
{!showSoccerTable && <th className="hidden sm:table-cell text-right py-1.5 px-2 w-12">PCT</th>}
{!showSoccerTable && hasGB && <th className="hidden sm:table-cell text-right py-1.5 px-2 w-10">GB</th>}
{!showSoccerTable && hasLastTen && <th className="hidden sm:table-cell text-right py-1.5 px-2 w-12">L10</th>}
{!showSoccerTable && hasStreak && <th className="text-right py-1.5 px-2 w-12">STK</th>}
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
{(!showSoccerTable && showStats) && <th className="text-right py-1.5 px-2 w-12">PCT</th>}
{(!showSoccerTable && showStats && hasGB) && <th className="text-right py-1.5 px-2 w-10">GB</th>}
{(!showSoccerTable && showStats && hasLastTen) && <th className="text-right py-1.5 px-2 w-12">L10</th>}
{(!showSoccerTable && showStats && hasStreak) && <th className="text-right py-1.5 px-2 w-12">STK</th>}
<th className="hidden sm:table-cell text-right py-1.5 pl-4 w-40">Mgr</th>
</tr>
</thead>
<tbody>
@ -286,7 +281,7 @@ function StandingsTable({
sectionRows.push(
<tr key={`h-${section.heading}`}>
<td
colSpan={totalCols}
colSpan={100}
className={`text-xs font-medium text-muted-foreground/60 uppercase tracking-wider pb-1 ${
sIdx > 0 ? "pt-5" : "pt-2"
}`}
@ -299,13 +294,20 @@ function StandingsTable({
section.rows.forEach((row, i) => {
const rank = getRank(row, i, section.rankMode);
const nextRow = section.rows[i + 1];
const nextRank = nextRow ? getRank(nextRow, i + 1, section.rankMode) : null;
const isLastBeforePlayoffLine =
section.playoffSpots > 0 &&
rank <= section.playoffSpots &&
nextRank !== null &&
nextRank > section.playoffSpots;
if (!playoffLineShown && section.playoffSpots > 0 && rank > section.playoffSpots) {
playoffLineShown = true;
sectionRows.push(
<tr key={`pl-${sIdx}`}>
<td colSpan={totalCols} className="py-0.5">
<div className="flex items-center gap-2">
<td colSpan={100} className="py-0.5">
<div className="flex w-full items-center gap-2">
<div className="flex-1 border-t border-dashed border-amber-500/40" />
<span className="text-[10px] text-amber-600/70 dark:text-amber-500/70 uppercase tracking-wide whitespace-nowrap font-medium px-1">
Playoff Line
@ -323,24 +325,29 @@ function StandingsTable({
sectionRows.push(
<tr
key={row.id}
className={`${showSubRow ? "" : "border-b border-border/50 last:border-0"} ${
className={`${isLastBeforePlayoffLine ? "" : "border-b border-border/50 last:border-0"} ${
isUserTeam ? "bg-primary/5" : "hover:bg-muted/30"
}`}
>
<td className="py-2 pr-2 text-muted-foreground tabular-nums">{rank}</td>
<td className="py-2 pl-2 pr-2 text-muted-foreground tabular-nums">{rank}</td>
<td className="py-2">
<span className={`font-medium ${isUserTeam ? "text-primary" : ""}`}>
{row.participant.shortName ?? row.participant.name}
</span>
{section.showDivisionLabel && row.division && (
<span className="ml-1.5 text-[10px] text-muted-foreground/50 uppercase tracking-wide">
{row.division}
</span>
{ownership && (
<div className="sm:hidden mt-0.5">
<TeamOwnerBadge
teamName={ownership.teamName}
ownerName={ownership.ownerName || undefined}
/>
</div>
)}
</td>
<td className={`${showSubRow ? "hidden sm:table-cell " : ""}py-2 px-2 text-right tabular-nums text-muted-foreground`}>
{(!showSoccerTable && showStats) && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{row.gamesPlayed}
</td>
)}
<td className="py-2 px-2 text-right tabular-nums font-medium">{row.wins}</td>
{showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums">{row.ties ?? 0}</td>
@ -366,22 +373,22 @@ function StandingsTable({
{row.wins * 2 + (row.otLosses ?? 0)}
</td>
)}
{!showSoccerTable && (
<td className="hidden sm:table-cell py-2 px-2 text-right tabular-nums text-muted-foreground">
{(!showSoccerTable && showStats) && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{formatWinPct(row.winPct, row.wins, row.gamesPlayed)}
</td>
)}
{!showSoccerTable && hasGB && (
<td className="hidden sm:table-cell py-2 px-2 text-right tabular-nums text-muted-foreground">
{(!showSoccerTable && showStats && hasGB) && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{formatGB(row.gamesBack)}
</td>
)}
{!showSoccerTable && hasLastTen && (
<td className="hidden sm:table-cell py-2 px-2 text-right tabular-nums text-muted-foreground">
{(!showSoccerTable && showStats && hasLastTen) && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{row.lastTen ?? "—"}
</td>
)}
{!showSoccerTable && hasStreak && (
{(!showSoccerTable && showStats && hasStreak) && (
<td className="py-2 px-2 text-right tabular-nums">
{row.streak ? (
<span
@ -398,7 +405,7 @@ function StandingsTable({
)}
</td>
)}
<td className="py-2 pl-4 text-right">
<td className="hidden sm:table-cell py-2 pl-4 text-right">
{ownership ? (
<div className="flex justify-end">
<TeamOwnerBadge
@ -413,42 +420,6 @@ function StandingsTable({
</td>
</tr>
);
if (showSubRow) {
sectionRows.push(
<tr
key={`${row.id}-sub`}
className={`sm:hidden border-b border-border/50 last:border-0 ${
isUserTeam ? "bg-primary/5" : "hover:bg-muted/30"
}`}
>
<td colSpan={totalCols} className="pb-2 pt-0 pr-2">
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-muted-foreground pl-4">
<span>
<span className="uppercase tracking-wide">GP</span>{" "}
<span className="tabular-nums text-foreground">{row.gamesPlayed}</span>
</span>
<span>
<span className="uppercase tracking-wide">PCT</span>{" "}
<span className="tabular-nums text-foreground">{formatWinPct(row.winPct, row.wins, row.gamesPlayed)}</span>
</span>
{hasGB && (
<span>
<span className="uppercase tracking-wide">GB</span>{" "}
<span className="tabular-nums text-foreground">{formatGB(row.gamesBack)}</span>
</span>
)}
{hasLastTen && (
<span>
<span className="uppercase tracking-wide">L10</span>{" "}
<span className="tabular-nums text-foreground">{row.lastTen ?? "—"}</span>
</span>
)}
</div>
</td>
</tr>
);
}
});
return sectionRows;
@ -470,6 +441,9 @@ export function RegularSeasonStandings({
playoffSpots = 8,
displayMode = "flat",
}: Props) {
const switchId = useId();
const [showStats, setShowStats] = useState(false);
if (standings.length === 0) return null;
const lastSyncedAt = standings
@ -485,7 +459,7 @@ export function RegularSeasonStandings({
? buildMlbSections(standings)
: buildFlatSections(standings, playoffSpots);
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, showSoccerTable };
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, showSoccerTable, showStats };
return (
<Card>
@ -503,6 +477,18 @@ export function RegularSeasonStandings({
</div>
</CardHeader>
<CardContent>
{!showSoccerTable && (
<div className="flex items-center justify-end gap-2 mb-2">
<label className="text-xs text-muted-foreground cursor-pointer" htmlFor={switchId}>
Details
</label>
<Switch
id={switchId}
checked={showStats}
onCheckedChange={setShowStats}
/>
</div>
)}
{groups.map((group) => (
<div key={group.conference} className="mb-6 last:mb-0">
{group.conference && (

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { RegularSeasonStandings } from "../RegularSeasonStandings";
function makeRow(overrides: Partial<{
@ -128,6 +128,7 @@ describe("RegularSeasonStandings", () => {
userParticipantIds={NO_USER_IDS}
/>
);
fireEvent.click(screen.getByRole("switch"));
expect(screen.getByText("STK")).toBeInTheDocument();
});
@ -184,7 +185,7 @@ describe("RegularSeasonStandings", () => {
expect(screen.getByText("69")).toBeInTheDocument();
});
it("renders conference headings and inline division labels when present", () => {
it("renders conference headings when standings have conference data", () => {
render(
<RegularSeasonStandings
standings={[
@ -198,9 +199,6 @@ describe("RegularSeasonStandings", () => {
// Conference group headings are rendered as <h3>
expect(screen.getByText(/Eastern Conference/i)).toBeInTheDocument();
expect(screen.getByText(/Western Conference/i)).toBeInTheDocument();
// Division is now shown as an inline label per row (not a section header)
expect(screen.getAllByText(/Atlantic/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Pacific/i).length).toBeGreaterThan(0);
});
it("shows ownership badge for drafted teams", () => {
@ -213,7 +211,8 @@ describe("RegularSeasonStandings", () => {
userParticipantIds={NO_USER_IDS}
/>
);
expect(screen.getByText("Alice")).toBeInTheDocument();
// Owner name appears in both the mobile inline badge and the desktop Mgr column
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
it("does not show badge for undrafted teams", () => {
@ -249,6 +248,7 @@ describe("RegularSeasonStandings", () => {
userParticipantIds={NO_USER_IDS}
/>
);
fireEvent.click(screen.getByRole("switch"));
const streakEl = screen.getByText("W5");
expect(streakEl.className).toContain("emerald");
});
@ -261,6 +261,7 @@ describe("RegularSeasonStandings", () => {
userParticipantIds={NO_USER_IDS}
/>
);
fireEvent.click(screen.getByRole("switch"));
const streakEl = screen.getByText("L3");
expect(streakEl.className).toContain("destructive");
});