brackt/app/components/scoring/SeasonStandings.tsx
Chris Parsons 4f111820ec
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>
2026-05-12 10:39:25 -07:00

241 lines
9.2 KiB
TypeScript

import { Fragment, useId, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
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;
position: number;
previousPosition?: number | null;
participant: {
id: string;
name: string;
};
}
interface TeamOwnership {
participantId: string;
teamName: string;
teamId: string;
ownerName?: string;
}
interface SeasonStandingsProps {
standings: SeasonStanding[];
teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[];
showOwnership?: boolean;
isFinalized?: boolean;
title?: string;
}
function getMovementIndicator(
currentPosition: number,
previousPosition?: number | null
) {
if (!previousPosition) return null;
const change = previousPosition - currentPosition;
if (change > 0) {
return (
<div className="flex items-center gap-1 text-emerald-400">
<TrendingUp className="h-3 w-3" />
<span className="text-xs">+{change}</span>
</div>
);
} else if (change < 0) {
return (
<div className="flex items-center gap-1 text-coral-accent">
<TrendingDown className="h-3 w-3" />
<span className="text-xs">{change}</span>
</div>
);
} else {
return (
<div className="flex items-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
</div>
);
}
}
export function SeasonStandings({
standings,
teamOwnerships = [],
userParticipantIds = [],
showOwnership = true,
isFinalized = false,
title = "Championship Standings",
}: SeasonStandingsProps) {
const switchId = useId();
const [showStats, setShowStats] = useState(false);
const userParticipantSet = new Set(userParticipantIds);
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
const checkIfTied = (standing: SeasonStanding): boolean =>
standings.some((o) => o.id !== standing.id && o.position === standing.position);
const hasChangeColumn = standings.some((s) => s.previousPosition);
const sorted = [...standings].toSorted((a, b) => a.position - b.position);
// Index of first row with position > 8; -1 when there is no cutoff.
const firstOver8Idx = sorted.findIndex((s) => s.position > 8);
return (
<Card>
<CardHeader>
<CardTitle>
<Flag className="inline mr-2 h-5 w-5" />
{title}
</CardTitle>
<CardDescription>
{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
</span>
) : (
<>
Current championship standings. Positions calculated from points (highest = 1st).
</>
)}
</CardDescription>
</CardHeader>
<CardContent>
{standings.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No standings data available yet.</p>
<p className="text-xs mt-1">
Championship points will appear here once results are entered.
</p>
</div>
) : (
<>
{/* 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>
)}
<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 && (
<th className="hidden sm:table-cell text-right py-1.5 pl-4 w-40">Drafted By</th>
)}
</tr>
</thead>
<tbody>
{sorted.map((standing, idx) => {
const ownership = showOwnership
? ownershipMap.get(standing.participant.id) ?? null
: null;
const isTied = checkIfTied(standing);
const isOwned = userParticipantSet.has(standing.participant.id);
const isTop8 = standing.position <= 8;
const showPointsLineBefore = firstOver8Idx !== -1 && idx === firstOver8Idx;
const isLastBeforePointsLine = firstOver8Idx !== -1 && idx === firstOver8Idx - 1;
return (
<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>
)}
<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.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>
{ownership && (
<div className="sm:hidden mt-0.5">
<TeamOwnerBadge
teamName={ownership.teamName}
ownerName={ownership.ownerName}
/>
</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>
</Card>
);
}