Add Storybook stories for new components from staged changes (#321)

This commit is contained in:
Chris Parsons 2026-04-23 22:09:25 -07:00 committed by GitHub
parent 41d45041c8
commit 01dacbce27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 3185 additions and 1807 deletions

View file

@ -89,6 +89,7 @@ html {
--sidebar-border: rgb(255 255 255 / 10%);
--sidebar-ring: #adf661;
--electric: #2ce1c1;
--brackt-gradient: linear-gradient(to bottom, var(--primary), var(--accent));
--amber-accent: oklch(0.79 0.17 70);
--coral-accent: oklch(0.68 0.19 35);
}

View file

@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TeamAvatar } from "./TeamAvatar";
const meta: Meta<typeof TeamAvatar> = {
title: "UI/TeamAvatar",
component: TeamAvatar,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof TeamAvatar>;
export const Default: Story = {
args: {
teamId: "team-1",
teamName: "Thunder Hawks",
},
};
export const Small: Story = {
args: {
teamId: "team-1",
teamName: "Thunder Hawks",
size: "sm",
},
};
export const Large: Story = {
args: {
teamId: "team-1",
teamName: "Thunder Hawks",
size: "lg",
},
};
export const WithLogo: Story = {
args: {
teamId: "team-2",
teamName: "Iron Eagles",
logoUrl: "https://placehold.co/48x48/1a1a2e/e0e0e0?text=IE",
},
};
export const SingleWordName: Story = {
args: {
teamId: "team-3",
teamName: "Superteam",
},
};
export const AllSizes: Story = {
name: "All Sizes — Side by Side",
render: () => (
<div className="flex items-end gap-4">
<div className="flex flex-col items-center gap-1">
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="sm" />
<span className="text-xs text-muted-foreground">sm</span>
</div>
<div className="flex flex-col items-center gap-1">
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="md" />
<span className="text-xs text-muted-foreground">md</span>
</div>
<div className="flex flex-col items-center gap-1">
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="lg" />
<span className="text-xs text-muted-foreground">lg</span>
</div>
</div>
),
};

View file

@ -1,19 +1,4 @@
const TEAM_AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
function hashTeamId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
}
return hash;
}
import { avatarColor } from "~/lib/avatar-colors";
interface TeamAvatarProps {
teamId: string;
@ -39,7 +24,7 @@ export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvata
);
}
const color = TEAM_AVATAR_COLORS[hashTeamId(teamId) % TEAM_AVATAR_COLORS.length];
const color = avatarColor(teamId);
const initials =
teamName
.split(/\s+/)

View file

@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CommissionerAutodraftDialog } from "./CommissionerAutodraftDialog";
const meta: Meta<typeof CommissionerAutodraftDialog> = {
title: "Draft/CommissionerAutodraftDialog",
component: CommissionerAutodraftDialog,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof CommissionerAutodraftDialog>;
export const AutodraftOff: Story = {
args: {
open: true,
onOpenChange: () => {},
teamId: "team-1",
teamName: "Thunder Hawks",
seasonId: "season-1",
isEnabled: false,
mode: "next_pick",
queueOnly: false,
onUpdate: () => {},
},
};
export const AutodraftNextQueue: Story = {
args: {
open: true,
onOpenChange: () => {},
teamId: "team-1",
teamName: "Shadow Hawks",
seasonId: "season-1",
isEnabled: true,
mode: "next_pick",
queueOnly: true,
onUpdate: () => {},
},
};
export const AutodraftAllPicks: Story = {
args: {
open: true,
onOpenChange: () => {},
teamId: "team-2",
teamName: "Iron Eagles",
seasonId: "season-1",
isEnabled: true,
mode: "while_on",
queueOnly: false,
onUpdate: () => {},
},
};
export const NoTeamSelected: Story = {
name: "No Team Selected (body hidden)",
args: {
open: true,
onOpenChange: () => {},
teamId: null,
teamName: undefined,
seasonId: "season-1",
isEnabled: false,
mode: "next_pick",
queueOnly: false,
onUpdate: () => {},
},
};

View file

@ -0,0 +1,57 @@
import { AutodraftSettings } from "~/components/AutodraftSettings";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog";
interface CommissionerAutodraftDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: string | null;
teamName: string | undefined;
seasonId: string;
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
onUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => void;
}
export function CommissionerAutodraftDialog({
open,
onOpenChange,
teamId,
teamName,
seasonId,
isEnabled,
mode,
queueOnly,
onUpdate,
}: CommissionerAutodraftDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
onOpenChange(isOpen);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Autodraft</DialogTitle>
<DialogDescription>
{teamId
? `Change autodraft for ${teamName ?? "this team"}`
: "Set a team's autodraft settings"}
</DialogDescription>
</DialogHeader>
{teamId && (
<AutodraftSettings
seasonId={seasonId}
teamId={teamId}
isEnabled={isEnabled}
mode={mode}
queueOnly={queueOnly}
isMyTurn={false}
onUpdate={onUpdate}
/>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CommissionerDraftControls } from "./CommissionerDraftControls";
const meta: Meta<typeof CommissionerDraftControls> = {
title: "Draft/CommissionerDraftControls",
component: CommissionerDraftControls,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof CommissionerDraftControls>;
export const PreDraft: Story = {
args: {
seasonStatus: "pre_draft",
isDraftComplete: false,
isPaused: false,
onStart: () => {},
onPause: () => {},
onResume: () => {},
},
};
export const DraftActive: Story = {
args: {
seasonStatus: "draft",
isDraftComplete: false,
isPaused: false,
onStart: () => {},
onPause: () => {},
onResume: () => {},
},
};
export const DraftPaused: Story = {
args: {
seasonStatus: "draft",
isDraftComplete: false,
isPaused: true,
onStart: () => {},
onPause: () => {},
onResume: () => {},
},
};
export const DraftComplete: Story = {
name: "Draft Complete (renders null)",
args: {
seasonStatus: "draft",
isDraftComplete: true,
isPaused: false,
onStart: () => {},
onPause: () => {},
onResume: () => {},
},
};

View file

@ -0,0 +1,43 @@
import { Button } from "~/components/ui/button";
interface CommissionerDraftControlsProps {
seasonStatus: string;
isDraftComplete: boolean;
isPaused: boolean;
onStart: () => void;
onPause: () => void;
onResume: () => void;
buttonClassName?: string;
}
export function CommissionerDraftControls({
seasonStatus,
isDraftComplete,
isPaused,
onStart,
onPause,
onResume,
buttonClassName,
}: CommissionerDraftControlsProps) {
if (seasonStatus === "pre_draft") {
return (
<Button className={buttonClassName} onClick={onStart}>
Start Draft
</Button>
);
}
if (seasonStatus === "draft" && !isDraftComplete) {
return isPaused ? (
<Button className={buttonClassName} onClick={onResume}>
Resume Draft
</Button>
) : (
<Button className={buttonClassName} variant="outline" onClick={onPause}>
Pause Draft
</Button>
);
}
return null;
}

View file

@ -238,3 +238,26 @@ export const CoronaStatesRow: Story = {
</div>
),
};
export const CurrentPreDraft: Story = {
name: "Current — Pre-Draft (Up Next)",
args: {
pickNumber: 1,
round: 1,
pickInRound: 1,
state: "current",
seasonStatus: "pre_draft",
},
};
export const CurrentPausedDraft: Story = {
name: "Current — Paused Draft",
args: {
pickNumber: 1,
round: 1,
pickInRound: 1,
state: "current",
seasonStatus: "draft",
draftPaused: true,
},
};

View file

@ -1,11 +1,9 @@
import { forwardRef, type CSSProperties, type ComponentPropsWithoutRef } from "react";
import { cn } from "~/lib/utils";
import type { SeasonStatus } from "~/models/season";
export type CoronaState =
| { type: "eliminated"; points: 0 }
| { type: "pending" }
| { type: "scored"; brightness: number; points: number };
import { BRACKT_GRADIENT } from "~/lib/brand";
export type { CoronaState } from "~/lib/corona-states";
import type { CoronaState } from "~/lib/corona-states";
type CoronaVariant =
| "gradient"
@ -30,7 +28,7 @@ type DraftPickCellProps = ComponentPropsWithoutRef<"div"> & {
};
const coronaClasses: Record<CoronaVariant, string> = {
gradient: "[background:linear-gradient(to_bottom,#adf661,#2ce1c1)]",
gradient: "[background:var(--brackt-gradient)]",
electric: "bg-electric",
emerald: "bg-emerald-500",
amber: "bg-amber-accent",
@ -45,7 +43,7 @@ function getCoronaStyle(cs: CoronaState): CSSProperties {
return { background: "rgba(255, 255, 255, 0.08)" };
case "scored":
return {
background: "linear-gradient(to bottom, #adf661, #2ce1c1)",
background: BRACKT_GRADIENT,
opacity: cs.brightness,
};
}
@ -86,7 +84,7 @@ function DraftPickCell({
{...rest}
>
{isCurrent ? (
<div className="absolute inset-0 rounded-lg [background:linear-gradient(to_bottom,#adf661,#2ce1c1)] animate-pulse-gradient" />
<div className="absolute inset-0 rounded-lg [background:var(--brackt-gradient)] animate-pulse-gradient" />
) : coronaState ? (
<div
className="absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg"
@ -146,3 +144,4 @@ function DraftPickCell({
</div>
);
});
DraftPickCell.displayName = "DraftPickCell";

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RollbackConfirmDialog } from "./RollbackConfirmDialog";
const meta: Meta<typeof RollbackConfirmDialog> = {
title: "Draft/RollbackConfirmDialog",
component: RollbackConfirmDialog,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof RollbackConfirmDialog>;
export const Default: Story = {
args: {
open: true,
onOpenChange: () => {},
pickNumber: 5,
picksCount: 3,
isRollingBack: false,
onConfirm: () => {},
},
};
export const SinglePick: Story = {
args: {
open: true,
onOpenChange: () => {},
pickNumber: 7,
picksCount: 1,
isRollingBack: false,
onConfirm: () => {},
},
};
export const RollingBack: Story = {
name: "Rolling Back...",
args: {
open: true,
onOpenChange: () => {},
pickNumber: 3,
picksCount: 5,
isRollingBack: true,
onConfirm: () => {},
},
};
export const Closed: Story = {
args: {
open: false,
onOpenChange: () => {},
pickNumber: null,
picksCount: 0,
isRollingBack: false,
onConfirm: () => {},
},
};

View file

@ -0,0 +1,42 @@
import { Button } from "~/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
interface RollbackConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pickNumber: number | null;
picksCount: number;
isRollingBack: boolean;
onConfirm: () => void;
}
export function RollbackConfirmDialog({
open,
onOpenChange,
pickNumber,
picksCount,
isRollingBack,
onConfirm,
}: RollbackConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Roll Back Draft</DialogTitle>
<DialogDescription>
{pickNumber !== null &&
`This will erase ${picksCount} pick${picksCount === 1 ? "" : "s"} (pick #${pickNumber} through the most recent) and return the draft to pick #${pickNumber}. This cannot be undone.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={isRollingBack}>
{isRollingBack ? "Rolling Back..." : "Roll Back"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TimeBankAdjustmentDialog } from "./TimeBankAdjustmentDialog";
const meta: Meta<typeof TimeBankAdjustmentDialog> = {
title: "Draft/TimeBankAdjustmentDialog",
component: TimeBankAdjustmentDialog,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof TimeBankAdjustmentDialog>;
export const AddTime: Story = {
args: {
open: true,
onOpenChange: () => {},
teamName: "Thunder Hawks",
amount: "30",
onAmountChange: () => {},
unit: "seconds",
onUnitChange: () => {},
direction: "add",
onDirectionChange: () => {},
isAdjusting: false,
onConfirm: () => {},
},
};
export const RemoveTime: Story = {
args: {
open: true,
onOpenChange: () => {},
teamName: "Shadow Hawks",
amount: "2",
onAmountChange: () => {},
unit: "minutes",
onUnitChange: () => {},
direction: "remove",
onDirectionChange: () => {},
isAdjusting: false,
onConfirm: () => {},
},
};
export const Adjusting: Story = {
name: "Adjusting...",
args: {
open: true,
onOpenChange: () => {},
teamName: "Iron Eagles",
amount: "1",
onAmountChange: () => {},
unit: "hours",
onUnitChange: () => {},
direction: "add",
onDirectionChange: () => {},
isAdjusting: true,
onConfirm: () => {},
},
};
export const NoTeamName: Story = {
name: "No Team Name",
args: {
open: true,
onOpenChange: () => {},
teamName: undefined,
amount: "15",
onAmountChange: () => {},
unit: "seconds",
onUnitChange: () => {},
direction: "add",
onDirectionChange: () => {},
isAdjusting: false,
onConfirm: () => {},
},
};
export const Closed: Story = {
args: {
open: false,
onOpenChange: () => {},
teamName: undefined,
amount: "",
onAmountChange: () => {},
unit: "seconds",
onUnitChange: () => {},
direction: "add",
onDirectionChange: () => {},
isAdjusting: false,
onConfirm: () => {},
},
};

View file

@ -0,0 +1,104 @@
import { Button } from "~/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
interface TimeBankAdjustmentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamName: string | undefined;
amount: string;
onAmountChange: (value: string) => void;
unit: "seconds" | "minutes" | "hours";
onUnitChange: (value: "seconds" | "minutes" | "hours") => void;
direction: "add" | "remove";
onDirectionChange: (value: "add" | "remove") => void;
isAdjusting: boolean;
onConfirm: () => void;
}
export function TimeBankAdjustmentDialog({
open,
onOpenChange,
teamName,
amount,
onAmountChange,
unit,
onUnitChange,
direction,
onDirectionChange,
isAdjusting,
onConfirm,
}: TimeBankAdjustmentDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Adjust Time Bank</DialogTitle>
<DialogDescription>
{teamName ? `Adjusting time for ${teamName}` : "Adjust a team's time bank"}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => onDirectionChange("add")}
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
direction === "add"
? "bg-emerald-500/20 border-emerald-500 text-emerald-400"
: "border-border text-muted-foreground hover:bg-muted"
}`}
>
Add Time
</button>
<button
type="button"
onClick={() => onDirectionChange("remove")}
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
direction === "remove"
? "bg-destructive/20 border-destructive text-destructive"
: "border-border text-muted-foreground hover:bg-muted"
}`}
>
Remove Time
</button>
</div>
<div className="flex gap-2">
<input
type="number"
min="0.001"
step="any"
value={amount}
onChange={(e) => onAmountChange(e.target.value)}
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
placeholder="Amount"
/>
<select
value={unit}
onChange={(e) => onUnitChange(e.target.value as "seconds" | "minutes" | "hours")}
className="px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
>
<option value="seconds">Seconds</option>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isAdjusting}
variant={direction === "remove" ? "destructive" : "default"}
>
{isAdjusting ? "Adjusting..." : direction === "add" ? "Add Time" : "Remove Time"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LeagueAvatar } from "./LeagueAvatar";
const meta: Meta<typeof LeagueAvatar> = {
title: "League/LeagueAvatar",
component: LeagueAvatar,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof LeagueAvatar>;
export const Default: Story = {
args: {
leagueId: "league-1",
leagueName: "Friday Night Ballers",
},
};
export const Small: Story = {
args: {
leagueId: "league-1",
leagueName: "Friday Night Ballers",
className: "h-5 w-5",
textClassName: "text-[10px]",
},
};
export const Large: Story = {
args: {
leagueId: "league-1",
leagueName: "Friday Night Ballers",
className: "h-16 w-16",
textClassName: "text-xl",
},
};
export const SingleWordName: Story = {
args: {
leagueId: "league-2",
leagueName: "Superteam",
},
};
export const AllSizes: Story = {
name: "All Sizes — Side by Side",
render: () => (
<div className="flex items-end gap-4">
<div className="flex flex-col items-center gap-1">
<LeagueAvatar leagueId="league-1" leagueName="Friday Night Ballers" className="h-5 w-5" textClassName="text-[10px]" />
<span className="text-xs text-muted-foreground">sm</span>
</div>
<div className="flex flex-col items-center gap-1">
<LeagueAvatar leagueId="league-1" leagueName="Friday Night Ballers" />
<span className="text-xs text-muted-foreground">default</span>
</div>
<div className="flex flex-col items-center gap-1">
<LeagueAvatar leagueId="league-1" leagueName="Friday Night Ballers" className="h-16 w-16" textClassName="text-xl" />
<span className="text-xs text-muted-foreground">lg</span>
</div>
</div>
),
};

View file

@ -1,19 +1,4 @@
const AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
function hashLeagueId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
}
return hash;
}
import { avatarColor } from "~/lib/avatar-colors";
interface LeagueAvatarProps {
leagueId: string;
@ -30,7 +15,7 @@ export function LeagueAvatar({
className = "h-10 w-10",
textClassName = "text-sm",
}: LeagueAvatarProps) {
const color = AVATAR_COLORS[hashLeagueId(leagueId) % AVATAR_COLORS.length];
const color = avatarColor(leagueId);
const initials =
leagueName
.split(/\s+/)

View file

@ -53,7 +53,7 @@ export function TeamsPanel({ teams, currentUserId, ownerMap, draftSlots }: Teams
</div>
<div className="h-2 w-full rounded-full bg-white/[0.08] overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-[#adf661] to-[#2ce1c1] transition-all"
className="h-full rounded-full bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] transition-all"
style={{ width: `${fillPercent}%` }}
/>
</div>

View file

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BracketDecor } from "./BracketDecor";
const meta: Meta<typeof BracketDecor> = {
title: "Marketing/BracketDecor",
component: BracketDecor,
parameters: {
layout: "fullscreen",
},
decorators: [
(Story) => (
<div className="relative h-[75vh] w-full bg-black overflow-hidden">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof BracketDecor>;
export const Default: Story = {
args: {},
};
export const Flipped: Story = {
args: {
flip: true,
},
};
export const MobileTall: Story = {
args: {
mobileTall: true,
},
};

View file

@ -0,0 +1,137 @@
import { BRACKT_GRADIENT_START, BRACKT_GRADIENT_END } from "~/lib/brand";
export function BracketDecor({ flip = false, mobileTall = false }: { flip?: boolean; mobileTall?: boolean }) {
const base = flip ? "r" : "l";
const id = mobileTall ? `${base}-m` : base;
const W = 399;
const H = mobileTall ? 2000 : 560;
const pad = 36;
const spacing = (H - 2 * pad) / 7;
const r1Ys = Array.from({ length: 8 }, (_, i) => pad + i * spacing);
const r2Ys = [0, 1, 2, 3].map((i) => (r1Ys[i * 2] + r1Ys[i * 2 + 1]) / 2);
const r3Ys = [0, 1].map((i) => (r2Ys[i * 2] + r2Ys[i * 2 + 1]) / 2);
const champY = (r3Ys[0] + r3Ys[1]) / 2;
const x0 = 4;
const x1 = 77;
const x2 = 161;
const x3 = 252;
const gradId = `grad-${id}`;
const glowId = `glow-${id}`;
const dotsGlowId = `dots-glow-${id}`;
const finalsGlowId = `finals-glow-${id}`;
const finalsHaloId = `finals-halo-${id}`;
const finalsFadeMaskGradId = `finals-fade-mask-grad-${id}`;
const finalsFadeMaskId = `finals-fade-mask-${id}`;
const finalsLineGlowId = `finals-line-glow-${id}`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
style={{
position: "absolute",
top: "50%",
transform: `translateY(-52%)${flip ? " scaleX(-1)" : ""}`,
[flip ? "right" : "left"]: 0,
pointerEvents: "none",
height: mobileTall ? "auto" : "75vh",
width: mobileTall ? "32vw" : "auto",
}}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2={H} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor={BRACKT_GRADIENT_START} />
<stop offset="100%" stopColor={BRACKT_GRADIENT_END} />
</linearGradient>
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={finalsGlowId} filterUnits="userSpaceOnUse"
x={x2 - 15} y={r3Ys[0] - 15} width={x3 - x2 + 30} height={r3Ys[1] - r3Ys[0] + 30}>
<feGaussianBlur stdDeviation="7" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={finalsHaloId} filterUnits="userSpaceOnUse"
x={x2 - 40} y={r3Ys[0] - 40} width={x3 - x2 + 80} height={r3Ys[1] - r3Ys[0] + 80}>
<feGaussianBlur stdDeviation="16" />
</filter>
<linearGradient id={finalsFadeMaskGradId} x1={x3} y1="0" x2={W} y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="white" />
<stop offset="50%" stopColor="white" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id={finalsFadeMaskId} maskUnits="userSpaceOnUse" x={x3} y={champY - 20} width={W - x3} height={40}>
<rect x="0" y="0" width={W} height={H} fill={`url(#${finalsFadeMaskGradId})`} />
</mask>
<filter id={finalsLineGlowId} filterUnits="userSpaceOnUse" x={x3 - 10} y={champY - 15} width={W - x3 + 20} height={30}>
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={dotsGlowId} x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter={`url(#${finalsHaloId})`} stroke={`url(#${gradId})`} strokeWidth={2} fill="none" opacity={0.75}>
{r3Ys.map((midY) => (
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
</g>
<g filter={`url(#${finalsGlowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
{r3Ys.map((midY) => (
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
</g>
<g filter={`url(#${glowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
{r1Ys.map((y) => (
<g key={y}>
<circle cx={x0} cy={y} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
<line x1={x0} y1={y} x2={x1} y2={y} />
</g>
))}
{r2Ys.map((midY, i) => (
<g key={midY}>
<line x1={x1} y1={r1Ys[i * 2]} x2={x1} y2={r1Ys[i * 2 + 1]} />
<circle cx={x1} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
<line x1={x1} y1={midY} x2={x2} y2={midY} />
</g>
))}
{r3Ys.map((midY, i) => (
<g key={midY}>
<line x1={x2} y1={r2Ys[i * 2]} x2={x2} y2={r2Ys[i * 2 + 1]} />
<circle cx={x2} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
<line x1={x2} y1={midY} x2={x3} y2={midY} />
</g>
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
<circle cx={x3} cy={champY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
</g>
<line
x1={x3} y1={champY} x2={W} y2={champY}
stroke={`url(#${gradId})`}
strokeWidth={1.5}
fill="none"
filter={`url(#${finalsLineGlowId})`}
mask={`url(#${finalsFadeMaskId})`}
/>
</svg>
);
}

View file

@ -1,17 +1,15 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { Users, Shuffle, Trophy, ChevronDown } from "lucide-react";
import { ChevronDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
import { BRACKT_GRADIENT } from "~/lib/brand";
import { BracketDecor } from "./BracketDecor";
import { SPORTS, HOW_IT_WORKS_STEPS, FEATURES } from "~/lib/landing-data";
// ─── Slot Machine ────────────────────────────────────────────────────────────
const SPORTS = [
"Football", "Baseball", "Hockey", "Basketball", "Soccer", "Tennis", "Golf",
"eSports", "Formula 1", "Aussie Rules", "Lacrosse", "Darts", "College Football", "Snooker", "March Madness",
];
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
@ -66,7 +64,7 @@ function SlotMachineHeadline() {
return (
<span
className="animate-sport-land block bg-clip-text text-transparent pb-[0.12em]"
style={{ backgroundImage: "linear-gradient(to bottom, #adf661, #2ce1c1)" }}
style={{ backgroundImage: BRACKT_GRADIENT }}
>
Every Sport.
</span>
@ -80,145 +78,6 @@ function SlotMachineHeadline() {
);
}
// ─── Bracket Decoration ──────────────────────────────────────────────────────
function BracketDecor({ flip = false, mobileTall = false }: { flip?: boolean; mobileTall?: boolean }) {
const base = flip ? "r" : "l";
const id = mobileTall ? `${base}-m` : base;
const W = 399;
const H = mobileTall ? 2000 : 560;
const pad = 36;
const spacing = (H - 2 * pad) / 7;
const r1Ys = Array.from({ length: 8 }, (_, i) => pad + i * spacing);
const r2Ys = [0, 1, 2, 3].map((i) => (r1Ys[i * 2] + r1Ys[i * 2 + 1]) / 2);
const r3Ys = [0, 1].map((i) => (r2Ys[i * 2] + r2Ys[i * 2 + 1]) / 2);
const champY = (r3Ys[0] + r3Ys[1]) / 2;
const x0 = 4;
const x1 = 77;
const x2 = 161;
const x3 = 252;
const gradId = `grad-${id}`;
const glowId = `glow-${id}`;
const dotsGlowId = `dots-glow-${id}`;
const finalsGlowId = `finals-glow-${id}`;
const finalsHaloId = `finals-halo-${id}`;
const finalsFadeMaskGradId = `finals-fade-mask-grad-${id}`;
const finalsFadeMaskId = `finals-fade-mask-${id}`;
const finalsLineGlowId = `finals-line-glow-${id}`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
style={{
position: "absolute",
top: "50%",
transform: `translateY(-52%)${flip ? " scaleX(-1)" : ""}`,
[flip ? "right" : "left"]: 0,
pointerEvents: "none",
height: mobileTall ? "auto" : "75vh",
width: mobileTall ? "32vw" : "auto",
}}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2={H} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#adf661" />
<stop offset="100%" stopColor="#2ce1c1" />
</linearGradient>
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={finalsGlowId} filterUnits="userSpaceOnUse"
x={x2 - 15} y={r3Ys[0] - 15} width={x3 - x2 + 30} height={r3Ys[1] - r3Ys[0] + 30}>
<feGaussianBlur stdDeviation="7" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={finalsHaloId} filterUnits="userSpaceOnUse"
x={x2 - 40} y={r3Ys[0] - 40} width={x3 - x2 + 80} height={r3Ys[1] - r3Ys[0] + 80}>
<feGaussianBlur stdDeviation="16" />
</filter>
<linearGradient id={finalsFadeMaskGradId} x1={x3} y1="0" x2={W} y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="white" />
<stop offset="50%" stopColor="white" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id={finalsFadeMaskId} maskUnits="userSpaceOnUse" x={x3} y={champY - 20} width={W - x3} height={40}>
<rect x="0" y="0" width={W} height={H} fill={`url(#${finalsFadeMaskGradId})`} />
</mask>
<filter id={finalsLineGlowId} filterUnits="userSpaceOnUse" x={x3 - 10} y={champY - 15} width={W - x3 + 20} height={30}>
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={dotsGlowId} x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter={`url(#${finalsHaloId})`} stroke={`url(#${gradId})`} strokeWidth={2} fill="none" opacity={0.75}>
{r3Ys.map((midY) => (
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
</g>
<g filter={`url(#${finalsGlowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
{r3Ys.map((midY) => (
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
</g>
<g filter={`url(#${glowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
{r1Ys.map((y) => (
<g key={y}>
<circle cx={x0} cy={y} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
<line x1={x0} y1={y} x2={x1} y2={y} />
</g>
))}
{r2Ys.map((midY, i) => (
<g key={midY}>
<line x1={x1} y1={r1Ys[i * 2]} x2={x1} y2={r1Ys[i * 2 + 1]} />
<circle cx={x1} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
<line x1={x1} y1={midY} x2={x2} y2={midY} />
</g>
))}
{r3Ys.map((midY, i) => (
<g key={midY}>
<line x1={x2} y1={r2Ys[i * 2]} x2={x2} y2={r2Ys[i * 2 + 1]} />
<circle cx={x2} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
<line x1={x2} y1={midY} x2={x3} y2={midY} />
</g>
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
<circle cx={x3} cy={champY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
</g>
<line
x1={x3} y1={champY} x2={W} y2={champY}
stroke={`url(#${gradId})`}
strokeWidth={1.5}
fill="none"
filter={`url(#${finalsLineGlowId})`}
mask={`url(#${finalsFadeMaskId})`}
/>
</svg>
);
}
// ─── Scroll Hint ─────────────────────────────────────────────────────────────
function ScrollHint() {
@ -270,45 +129,6 @@ function SportTicker() {
);
}
// ─── Static data ──────────────────────────────────────────────────────────────
const HOW_IT_WORKS_STEPS = [
{
icon: Users,
title: "Create a League",
body: "Start a league, pick your sports (we can help), and invite your friends. It takes 30 seconds to set the stage.",
},
{
icon: Shuffle,
title: "Draft Your Teams",
body: "Pick teams from all the sports in order to build a superteam to win you bragging rights! Draft your way, slow or fast, with standard timers or high-pressure chess clocks.",
},
{
icon: Trophy,
title: "Watch & Win",
body: "Your teams earn points for how well they perform in the playoffs. Follow your rooting interests along the way, and gloat over your leaguemates when they fall.",
},
];
const FEATURES = [
{
title: "Dozens of Sports and Counting",
body: "Draft from a massive selection of sports including major US leagues, college athletics, and global niches like professional darts or snooker. We are constantly adding new sports to ensure your league has competitive action throughout the entire year.",
},
{
title: "The Smart Draft Room",
body: "Our interface supports any pace. Use a tactical chess clock with Fischer increments to reward fast decisions, or run a multi-day slow draft with customizable overnight pauses. The system ensures no one is forced to make a pick in the middle of the night.",
},
{
title: "Your Master Calendar",
body: "Stop switching between different apps to track your roster. Every game, match, and tournament for your specific teams is synced into a single view. You will always know exactly when your points are on the line without any manual tracking.",
},
{
title: "Zero Maintenance",
body: "Brackt is a draft and done experience. Once the draft is finished, your work is over because we handle all scoring and rankings automatically. You can even paste a Discord webhook URL to get live scoring updates sent directly to your group chat.",
},
];
// ─── Main Landing Page ────────────────────────────────────────────────────────
export function LandingPage() {

View file

@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ScoringPointsTable, QualifyingPointsTable } from "./ScoringTables";
const meta: Meta<typeof ScoringPointsTable> = {
title: "Marketing/ScoringTables",
component: ScoringPointsTable,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof ScoringPointsTable>;
export const ScoringPoints: Story = {
render: (args) => <ScoringPointsTable {...args} />,
args: {},
};
export const QualifyingPoints: Story = {
render: (args) => <QualifyingPointsTable {...args} />,
args: {},
};
export const BothTables: Story = {
name: "Both Tables — Side by Side",
render: () => (
<div className="flex flex-col sm:flex-row gap-4 max-w-2xl">
<ScoringPointsTable className="flex-1" />
<QualifyingPointsTable className="flex-1" />
</div>
),
};

View file

@ -0,0 +1,51 @@
interface TableProps {
className?: string;
}
export function ScoringPointsTable({ className }: TableProps) {
return (
<div className={`bg-muted rounded-lg overflow-hidden ${className ?? ""}`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">Finish</th>
<th className="text-right p-3 font-semibold text-foreground">Points</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr><td className="p-3">1st Place</td><td className="p-3 text-right font-semibold text-foreground">100</td></tr>
<tr><td className="p-3">2nd Place</td><td className="p-3 text-right font-semibold text-foreground">70</td></tr>
<tr><td className="p-3">3rd Place</td><td className="p-3 text-right font-semibold text-foreground">50</td></tr>
<tr><td className="p-3">4th Place</td><td className="p-3 text-right font-semibold text-foreground">40</td></tr>
<tr><td className="p-3">5th6th Place</td><td className="p-3 text-right font-semibold text-foreground">25</td></tr>
<tr><td className="p-3">7th8th Place</td><td className="p-3 text-right font-semibold text-foreground">15</td></tr>
</tbody>
</table>
</div>
);
}
export function QualifyingPointsTable({ className }: TableProps) {
return (
<div className={`bg-muted rounded-lg overflow-hidden ${className ?? ""}`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">Major Finish</th>
<th className="text-right p-3 font-semibold text-foreground">Qualifying Points</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr><td className="p-3">1st</td><td className="p-3 text-right font-semibold text-foreground">20 QP</td></tr>
<tr><td className="p-3">2nd</td><td className="p-3 text-right font-semibold text-foreground">14 QP</td></tr>
<tr><td className="p-3">3rd</td><td className="p-3 text-right font-semibold text-foreground">10 QP</td></tr>
<tr><td className="p-3">4th</td><td className="p-3 text-right font-semibold text-foreground">8 QP</td></tr>
<tr><td className="p-3">5th6th</td><td className="p-3 text-right font-semibold text-foreground">5 QP</td></tr>
<tr><td className="p-3">7th8th</td><td className="p-3 text-right font-semibold text-foreground">3 QP</td></tr>
<tr><td className="p-3">9th12th</td><td className="p-3 text-right font-semibold text-foreground">2 QP</td></tr>
<tr><td className="p-3">13th16th</td><td className="p-3 text-right font-semibold text-foreground">1 QP</td></tr>
</tbody>
</table>
</div>
);
}

View file

@ -28,8 +28,8 @@ export function Navbar({ isAdmin }: NavbarProps) {
<img src={logoUrl} alt="Brackt" className="h-14" />
</Link>
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
<Link to="/how-to-play" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] px-3 py-2">How To Play</Link>
<Link to="/rules" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] px-3 py-2">Rules</Link>
<Link to="/how-to-play" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:[background:var(--brackt-gradient)] px-3 py-2">How To Play</Link>
<Link to="/rules" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:[background:var(--brackt-gradient)] px-3 py-2">Rules</Link>
</nav>
</div>

View file

@ -0,0 +1,172 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import { useRoundTransition } from "~/hooks/useRoundTransition";
import {
TreeColumns,
BracketMatchSlot,
SLOT_WIDTH,
LABEL_HEIGHT,
DESIRED_CARD_HEIGHT,
CARD_GAP,
MAX_CARD_HEIGHT,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
interface BracketTreePaginatedProps {
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
/** Index of the first scoring round — default page starts here */
firstScoringRoundIdx?: number;
thirdPlaceRound?: string;
}
export function BracketTreePaginated({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
firstScoringRoundIdx,
thirdPlaceRound,
}: BracketTreePaginatedProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const defaultPage = Math.max(
0,
Math.min(
firstScoringRoundIdx !== undefined
? Math.max(0, firstScoringRoundIdx - 1)
: mainRounds.length - 2,
mainRounds.length - 2,
),
);
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
mainRounds.length - 2,
defaultPage,
);
const targetPage = anim ? anim.toPage : page;
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
const label = labelRounds[1] ? `${labelRounds[0]}${labelRounds[1]}` : labelRounds[0];
const calcHeight = (p: number) => {
const rs = mainRounds.slice(p, p + 2);
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
};
const pageHeight = calcHeight(page);
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
const visibleRounds = mainRounds.slice(page, page + 2);
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
let leftRounds: string[];
let rightRounds: string[] = [];
let leftHeight: number;
let rightHeight = 0;
let settlingTransition = false;
if (anim?.phase === "sliding") {
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
} else if (anim?.phase === "settling") {
leftRounds = toRounds;
leftHeight = animToHeight;
settlingTransition = true;
} else {
leftRounds = visibleRounds;
leftHeight = pageHeight;
}
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
return (
<div>
<div className="flex items-center gap-2 mb-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(page - 1)}
disabled={page === 0 || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Previous rounds"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
{label}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(page + 1)}
disabled={page + 2 >= mainRounds.length || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Next rounds"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
<div
ref={anim?.phase === "sliding" ? stripRef : undefined}
style={{
display: "flex",
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
}}
onTransitionEnd={handleTransitionEnd}
>
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={leftRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={leftHeight}
transitionDuration={settlingTransition ? 500 : undefined}
/>
</div>
{anim?.phase === "sliding" && (
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={rightRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={rightHeight}
/>
</div>
)}
</div>
</div>
{thirdPlaceMatch && (
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
<div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
>
3rd Place
</div>
<BracketMatchSlot
match={thirdPlaceMatch}
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
)}
</div>
);
}

View file

@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import { avatarColor } from "~/lib/avatar-colors";
import { BRACKT_GRADIENT } from "~/lib/brand";
export interface BracketMatch {
id: string;
@ -29,22 +28,12 @@ export interface BracketOwnership {
const COLUMN_WIDTH = 152;
const CONNECTOR_WIDTH = 24;
const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; // viewport width for 2 columns
const LABEL_HEIGHT = 32;
const CARD_GAP = 14; // vertical gap between cards (split top/bottom)
const DESIRED_CARD_HEIGHT = 112; // target card height — tall enough to show owner info
const MAX_CARD_HEIGHT = 140; // cap for sparse later rounds
export const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH;
export const LABEL_HEIGHT = 32;
export const CARD_GAP = 14;
export const DESIRED_CARD_HEIGHT = 112;
export const MAX_CARD_HEIGHT = 140;
// Avatar color palette (matches TeamOwnerBadge)
const AVATAR_COLORS = ["#adf661", "#2ce1c1", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6"];
function hashName(name: string): number {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff;
return h;
}
function avatarColor(name: string) {
return AVATAR_COLORS[hashName(name) % AVATAR_COLORS.length];
}
function formatScore(score: string | null): string | null {
if (!score) return null;
@ -191,7 +180,7 @@ export function BracketMatchSlot({
// Corona glow: gradient for complete matches, electric for user's picks, subtle white for pending
const coronaStyle: React.CSSProperties = match.isComplete
? { background: "linear-gradient(to bottom, #adf661, #2ce1c1)" }
? { background: BRACKT_GRADIENT }
: matchHasOwned
? { background: "rgba(44, 225, 193, 0.4)" }
: { background: "rgba(255, 255, 255, 0.07)" };
@ -462,205 +451,3 @@ export function BracketTreeView({
);
}
// ─── Paginated mobile tree ────────────────────────────────────────────────────
interface BracketTreePaginatedProps {
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
/** Index of the first scoring round — default page starts here */
firstScoringRoundIdx?: number;
thirdPlaceRound?: string;
}
interface AnimState {
fromPage: number;
toPage: number;
dir: "left" | "right";
phase: "sliding" | "settling";
}
export function BracketTreePaginated({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
firstScoringRoundIdx,
thirdPlaceRound,
}: BracketTreePaginatedProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const defaultPage = Math.max(
0,
Math.min(
firstScoringRoundIdx !== undefined
? Math.max(0, firstScoringRoundIdx - 1)
: mainRounds.length - 2,
mainRounds.length - 2,
),
);
const [page, setPage] = useState(defaultPage);
const [anim, setAnim] = useState<AnimState | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
// Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition
useEffect(() => {
if (!anim || anim.phase !== "sliding" || !stripRef.current) return;
const el = stripRef.current;
const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0;
const raf = requestAnimationFrame(() => {
el.style.transition = "transform 200ms ease";
el.style.transform = `translateX(${finalX}px)`;
});
return () => cancelAnimationFrame(raf);
}, [anim]);
// Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions
useEffect(() => {
if (!anim || anim.phase !== "settling") return;
const timer = setTimeout(() => setAnim(null), 520);
return () => clearTimeout(timer);
}, [anim]);
const navigate = (newPage: number) => {
if (anim || newPage < 0 || newPage > mainRounds.length - 2) return;
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
};
const handleTransitionEnd = () => {
if (!anim || anim.phase !== "sliding") return;
if (stripRef.current) {
stripRef.current.style.transition = "none";
stripRef.current.style.transform = "translateX(0)";
}
setPage(anim.toPage);
setAnim(prev => prev ? { ...prev, phase: "settling" } : null);
};
const targetPage = anim ? anim.toPage : page;
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
const label = labelRounds[1] ? `${labelRounds[0]}${labelRounds[1]}` : labelRounds[0];
const calcHeight = (p: number) => {
const rs = mainRounds.slice(p, p + 2);
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
};
const pageHeight = calcHeight(page);
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
const visibleRounds = mainRounds.slice(page, page + 2);
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
// Sliding: show from/to slots side-by-side at their respective heights, no transitions
// Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly
// Null: show current page
let leftRounds: string[];
let rightRounds: string[] = [];
let leftHeight: number;
let rightHeight = 0;
let settlingTransition = false;
if (anim?.phase === "sliding") {
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
} else if (anim?.phase === "settling") {
leftRounds = toRounds;
leftHeight = animToHeight;
settlingTransition = true;
} else {
leftRounds = visibleRounds;
leftHeight = pageHeight;
}
// During settling, minHeight transitions from animFromHeight to animToHeight in sync with cards
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
return (
<div>
<div className="flex items-center gap-2 mb-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(page - 1)}
disabled={page === 0 || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Previous rounds"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
{label}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(page + 1)}
disabled={page + 2 >= mainRounds.length || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Next rounds"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
<div
ref={anim?.phase === "sliding" ? stripRef : undefined}
style={{
display: "flex",
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
}}
onTransitionEnd={handleTransitionEnd}
>
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={leftRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={leftHeight}
transitionDuration={settlingTransition ? 500 : undefined}
/>
</div>
{anim?.phase === "sliding" && (
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={rightRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={rightHeight}
/>
</div>
)}
</div>
</div>
{thirdPlaceMatch && (
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
<div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
>
3rd Place
</div>
<BracketMatchSlot
match={thirdPlaceMatch}
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
)}
</div>
);
}

View file

@ -1,10 +1,6 @@
import type { ConferenceGroup } from "~/lib/bracket-templates";
import {
TreeColumns,
BracketTreePaginated,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
interface NbaBracketLayoutProps {
matches: Array<{ round: string; matchNumber: number }>;

View file

@ -10,13 +10,9 @@ import {
import { Trophy, Star } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { TeamAvatar } from "~/components/TeamAvatar";
import {
BracketTreeView,
BracketTreePaginated,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { RankingsRow } from "./RankingsRow";
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -437,38 +433,17 @@ export function PlayoffBracket({
<CardContent className="px-3 sm:px-6">
<div className="space-y-3">
{bracketWinner && (
<div className={rankingRowCls(1)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
/>
<div className="min-w-0">
<p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
{bracketWinner.name}
{winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</p>
{winnerOwnership?.ownerName && (
<p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
)}
</div>
</div>
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
<span className="text-2xl font-bold leading-none">1</span>
</div>
{pointsMap.size > 0 && (
<>
<div className="h-8 w-px bg-border shrink-0 self-center" />
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
<span className="text-2xl font-bold leading-none text-electric">{winnerPts ?? 0}</span>
</div>
</>
)}
</div>
</div>
<RankingsRow
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
participantName={bracketWinner.name}
ownerName={winnerOwnership?.ownerName}
isOwned={winnerIsOwned}
rankLabel={1}
points={winnerPts}
showPoints={pointsMap.size > 0}
rowClass={rankingRowCls(1)}
/>
)}
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
@ -476,38 +451,18 @@ export function PlayoffBracket({
const pts = pointsMap.get(entry.participant.id);
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
return (
<div key={entry.participant.id} className={rankingRowCls(numRank)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar
teamId={entry.ownership?.teamId ?? entry.participant.id}
teamName={entry.ownership?.teamName ?? entry.participant.name}
/>
<div className="min-w-0">
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
{entry.participant.name}
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</p>
{entry.ownership?.ownerName && (
<p className="text-xs text-muted-foreground truncate">{entry.ownership.ownerName}</p>
)}
</div>
</div>
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
<span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
</div>
{pointsMap.size > 0 && (
<>
<div className="h-8 w-px bg-border shrink-0 self-center" />
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
</div>
</>
)}
</div>
</div>
<RankingsRow
key={entry.participant.id}
teamId={entry.ownership?.teamId ?? entry.participant.id}
teamName={entry.ownership?.teamName ?? entry.participant.name}
participantName={entry.participant.name}
ownerName={entry.ownership?.ownerName}
isOwned={isOwned}
rankLabel={entry.rankLabel}
points={pts}
showPoints={pointsMap.size > 0}
rowClass={rankingRowCls(numRank)}
/>
);
})}
@ -516,31 +471,17 @@ export function PlayoffBracket({
const ownership = ownershipMap.get(p.id);
const pts = pointsMap.get(p.id);
return (
<div key={p.id} className={rankingRowCls(undefined)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar
teamId={ownership?.teamId ?? p.id}
teamName={ownership?.teamName ?? p.name}
/>
<div className="min-w-0">
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
{p.name}
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</p>
{ownership?.ownerName && (
<p className="text-xs text-muted-foreground truncate">{ownership.ownerName}</p>
)}
</div>
</div>
{pointsMap.size > 0 && (
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
</div>
</div>
)}
</div>
<RankingsRow
key={p.id}
teamId={ownership?.teamId ?? p.id}
teamName={ownership?.teamName ?? p.name}
participantName={p.name}
ownerName={ownership?.ownerName}
isOwned={isOwned}
points={pts}
showPoints={pointsMap.size > 0}
rowClass={rankingRowCls(undefined)}
/>
);
})}
</div>

View file

@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RankingsRow } from "./RankingsRow";
const meta: Meta<typeof RankingsRow> = {
title: "Scoring/RankingsRow",
component: RankingsRow,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof RankingsRow>;
const baseArgs = {
teamId: "team-1",
teamName: "Thunder Hawks",
participantName: "LeBron James",
ownerName: "Chris M." as string | null,
isOwned: false,
rowClass: "flex items-center justify-between p-3 rounded-lg bg-card border border-border",
};
export const WithRankAndPoints: Story = {
args: {
...baseArgs,
rankLabel: 1,
points: 100,
showPoints: true,
},
};
export const WithRankOnly: Story = {
args: {
...baseArgs,
rankLabel: 3,
showPoints: false,
},
};
export const OwnedByUser: Story = {
args: {
...baseArgs,
participantName: "Jayson Tatum",
isOwned: true,
rankLabel: 2,
points: 70,
showPoints: true,
},
};
export const WithOwnerName: Story = {
args: {
...baseArgs,
ownerName: "Sarah J.",
rankLabel: 4,
points: 40,
showPoints: true,
},
};
export const WithoutRankOrPoints: Story = {
args: {
...baseArgs,
showPoints: false,
},
};

View file

@ -0,0 +1,62 @@
import { Star } from "lucide-react";
import { TeamAvatar } from "~/components/TeamAvatar";
interface RankingsRowProps {
teamId: string;
teamName: string;
participantName: string;
ownerName?: string | null;
isOwned: boolean;
rankLabel?: string | number;
points?: number;
showPoints: boolean;
rowClass: string;
}
export function RankingsRow({
teamId,
teamName,
participantName,
ownerName,
isOwned,
rankLabel,
points,
showPoints,
rowClass,
}: RankingsRowProps) {
return (
<div className={rowClass}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar teamId={teamId} teamName={teamName} />
<div className="min-w-0">
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
{participantName}
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</p>
{ownerName && (
<p className="text-xs text-muted-foreground truncate">{ownerName}</p>
)}
</div>
</div>
{(rankLabel !== undefined || showPoints) && (
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
{rankLabel !== undefined && (
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
<span className="text-2xl font-bold leading-none">{rankLabel}</span>
</div>
)}
{showPoints && rankLabel !== undefined && (
<div className="h-8 w-px bg-border shrink-0 self-center" />
)}
{showPoints && (
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
<span className="text-2xl font-bold leading-none text-electric">{points ?? 0}</span>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,12 +1,7 @@
import { cn } from "~/lib/utils";
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
import {
TreeColumns,
BracketTreePaginated,
BracketMatchSlot,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
interface TabbedBracketLayoutProps {
rounds: string[];

View file

@ -24,7 +24,7 @@ function CustomLegend({ payload, onHover, onLeave }: CustomLegendProps) {
onMouseEnter={() => onHover(entry.value)}
onMouseLeave={onLeave}
role="button"
aria-label={`Toggle ${entry.value} line`}
aria-label={`Highlight ${entry.value} line`}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {

View file

@ -1,3 +1,5 @@
import { BRACKT_GRADIENT_START, BRACKT_GRADIENT_END } from "~/lib/brand";
/**
* Renders hidden SVG gradient definitions that can be referenced by ID
* anywhere in the document via stroke="url(#brackt-primary-gradient)" etc.
@ -12,8 +14,8 @@ export function BracktGradients() {
userSpaceOnUse + 024 matches the Lucide icon viewBox so horizontal
strokes (zero bounding-box height) don't produce a degenerate gradient. */}
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#adf661" />
<stop offset="100%" stopColor="#2ce1c1" />
<stop offset="0%" stopColor={BRACKT_GRADIENT_START} />
<stop offset="100%" stopColor={BRACKT_GRADIENT_END} />
</linearGradient>
</defs>
</svg>

View file

@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] text-primary-foreground hover:opacity-90",
"[background:var(--brackt-gradient)] text-primary-foreground hover:opacity-90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:

View file

@ -9,7 +9,7 @@ const cardVariants = cva("", {
default:
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
tile:
"flex rounded-lg pr-1 [background:linear-gradient(to_bottom,#adf661,#2ce1c1)]",
"flex rounded-lg pr-1 [background:var(--brackt-gradient)]",
},
},
defaultVariants: {

View file

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TeamOwnerBadge } from "./team-owner-badge";
const meta: Meta<typeof TeamOwnerBadge> = {
title: "UI/TeamOwnerBadge",
component: TeamOwnerBadge,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof TeamOwnerBadge>;
export const LeftAligned: Story = {
args: {
teamName: "Thunder Hawks",
ownerName: "Chris M.",
},
};
export const RightAligned: Story = {
args: {
teamName: "Shadow Hawks",
ownerName: "Sarah J.",
align: "right",
},
};
export const WithoutOwner: Story = {
args: {
teamName: "Iron Eagles",
},
};
export const LongNames: Story = {
name: "Long Names (truncation)",
args: {
teamName: "The Incredibly Long Named Fantasy Team",
ownerName: "Alexander Hamilton-Washington",
},
};
export const AllVariants: Story = {
name: "All Variants — Side by Side",
render: () => (
<div className="flex flex-col gap-4 max-w-xs">
<TeamOwnerBadge teamName="Thunder Hawks" ownerName="Chris M." />
<TeamOwnerBadge teamName="Shadow Hawks" ownerName="Sarah J." align="right" />
<TeamOwnerBadge teamName="Iron Eagles" />
<TeamOwnerBadge teamName="The Incredibly Long Named Fantasy Team" ownerName="Alexander Hamilton-Washington" />
</div>
),
};

View file

@ -1,19 +1,4 @@
const AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
function hashName(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) & 0xffff;
}
return hash;
}
import { avatarColor } from "~/lib/avatar-colors";
interface TeamOwnerBadgeProps {
teamName: string;
@ -33,7 +18,7 @@ export function TeamOwnerBadge({
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
const color = AVATAR_COLORS[hashName(teamName) % AVATAR_COLORS.length];
const color = avatarColor(teamName);
const avatar = (
<div

View file

@ -0,0 +1,251 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { getDraftPicksForSeason } from "~/models/draft-pick";
import type { findSeasonWithTeamsAndLeague } from "~/models/season";
import type { getSeasonTimers } from "~/models/draft-timer";
import type { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
import type { AutodraftStatusEntry, QueueItem } from "~/hooks/useDraftRoomState";
type DraftPicks = Awaited<ReturnType<typeof getDraftPicksForSeason>>;
type Season = NonNullable<Awaited<ReturnType<typeof findSeasonWithTeamsAndLeague>>>;
type Timers = Awaited<ReturnType<typeof getSeasonTimers>>;
type AutodraftSettings = Awaited<ReturnType<typeof getSeasonAutodraftSettings>>;
interface UseDraftAuthRecoveryParams {
reconnectCount: number;
revalidate: () => void;
revalidatorState: "idle" | "loading" | "submitting";
clerkUserId: string | null | undefined;
currentUserId: string | null | undefined;
getToken: (opts?: { skipCache?: boolean }) => Promise<string | null>;
userAutodraftSettings: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean } | null;
// Loader data for revalidation sync
draftPicks: DraftPicks;
season: Season;
userQueue: QueueItem[];
timers: Timers;
autodraftSettings: AutodraftSettings;
// State setters to sync after revalidation
setPicks: (picks: DraftPicks) => void;
setCurrentPick: (pick: number) => void;
setIsPaused: (paused: boolean) => void;
setIsDraftComplete: (complete: boolean) => void;
setQueue: (queue: QueueItem[]) => void;
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
}
const MAX_AUTH_RECOVERY_ATTEMPTS = 3;
export function useDraftAuthRecovery({
reconnectCount,
revalidate,
revalidatorState,
clerkUserId,
currentUserId,
getToken,
userAutodraftSettings,
draftPicks,
season,
userQueue,
timers,
autodraftSettings,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setQueue,
setTeamTimers,
setAutodraftStatus,
}: UseDraftAuthRecoveryParams) {
const [authDegraded, setAuthDegraded] = useState(false);
const [userAutodraft, setUserAutodraft] = useState({
isEnabled: userAutodraftSettings?.isEnabled || false,
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
queueOnly: userAutodraftSettings?.queueOnly || false,
});
const userAutodraftRef = useRef(userAutodraft);
useEffect(() => {
userAutodraftRef.current = userAutodraft;
}, [userAutodraft]);
// Re-fetch loader data after each reconnect so stale picks/timers are refreshed.
const revalidationRetryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (reconnectCount > 0) {
revalidate();
if (revalidationRetryRef.current) {
clearTimeout(revalidationRetryRef.current);
}
revalidationRetryRef.current = setTimeout(() => {
revalidate();
revalidationRetryRef.current = null;
}, 3000);
}
return () => {
if (revalidationRetryRef.current) {
clearTimeout(revalidationRetryRef.current);
}
};
}, [reconnectCount, revalidate]);
// Guard against Clerk JWT expiry race.
const authRecoveryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const authRecoveryAttemptsRef = useRef(0);
useEffect(() => {
if (clerkUserId && !currentUserId) {
if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) {
setAuthDegraded(true);
return;
}
clearTimeout(authRecoveryTimerRef.current);
authRecoveryTimerRef.current = setTimeout(() => {
authRecoveryAttemptsRef.current += 1;
revalidate();
}, 100);
} else if (currentUserId) {
authRecoveryAttemptsRef.current = 0;
}
return () => clearTimeout(authRecoveryTimerRef.current);
}, [clerkUserId, currentUserId, revalidate]);
// Proactively refresh the Clerk JWT when the tab becomes visible again.
useEffect(() => {
if (authDegraded) return;
let aborted = false;
let inFlight = false;
const handleVisibilityChange = async () => {
if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return;
inFlight = true;
try {
const token = await getToken({ skipCache: true });
if (aborted) return;
if (token !== null) {
if (!currentUserId) {
revalidate();
}
} else {
await new Promise((r) => setTimeout(r, 2500));
if (aborted) return;
const retryToken = await getToken({ skipCache: true });
if (aborted) return;
if (retryToken !== null) {
revalidate();
} else {
setAuthDegraded(true);
}
}
} catch {
try {
await new Promise((r) => setTimeout(r, 2500));
if (aborted) return;
const retryToken = await getToken({ skipCache: true });
if (aborted) return;
if (retryToken !== null) {
revalidate();
} else {
setAuthDegraded(true);
}
} catch {
if (!aborted) setAuthDegraded(true);
}
} finally {
inFlight = false;
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
aborted = true;
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]);
// Track revalidation lifecycle to sync local state from fresh loader data.
const revalidatorStateRef = useRef(revalidatorState);
const isRevalidatingRef = useRef(false);
const pendingPicksDuringRevalidationRef = useRef<DraftPicks>([]);
const draftPicksAtRevalidationStartRef = useRef(draftPicks);
const userQueueAtRevalidationStartRef = useRef(userQueue);
const pendingQueueMutationsRef = useRef(0);
useEffect(() => {
const prev = revalidatorStateRef.current;
revalidatorStateRef.current = revalidatorState;
if (prev !== "loading" && revalidatorState === "loading") {
isRevalidatingRef.current = true;
pendingPicksDuringRevalidationRef.current = [];
draftPicksAtRevalidationStartRef.current = draftPicks;
userQueueAtRevalidationStartRef.current = userQueue;
} else if (prev === "loading" && revalidatorState === "idle") {
isRevalidatingRef.current = false;
if (draftPicks === draftPicksAtRevalidationStartRef.current) {
pendingPicksDuringRevalidationRef.current = [];
return;
}
const dbPickIds = new Set(draftPicks.map((p) => p.id));
const missedPicks = pendingPicksDuringRevalidationRef.current.filter(
(p) => !dbPickIds.has(p.id)
);
pendingPicksDuringRevalidationRef.current = [];
setPicks([...draftPicks, ...missedPicks]);
setCurrentPick(season.currentPickNumber || 1);
setIsPaused(season.draftPaused || false);
setIsDraftComplete(
season.status === "active" || season.status === "completed"
);
if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) {
setQueue(userQueue);
}
if (timers.length > 0) {
setTeamTimers((currentTimers) => {
const updated = { ...currentTimers };
timers.forEach((timer) => {
updated[timer.teamId] = timer.timeRemaining;
});
return updated;
});
}
setAutodraftStatus(() => {
const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting) => {
status[setting.teamId] = {
isEnabled: setting.isEnabled,
mode: setting.mode,
queueOnly: setting.queueOnly,
};
});
return status;
});
}
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId,
setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus]);
const authFetch = useCallback(async (url: string, init?: RequestInit): Promise<Response | null> => {
const response = await fetch(url, init);
if (response.status === 401) {
setAuthDegraded(true);
return null;
}
return response;
}, []);
return {
authDegraded,
authFetch,
userAutodraft,
setUserAutodraft,
userAutodraftRef,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
};
}

View file

@ -0,0 +1,164 @@
import { useState } from "react";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import type * as schema from "~/database/schema";
import type { getDraftPicksForSeason } from "~/models/draft-pick";
import type { findSeasonWithTeamsAndLeague } from "~/models/season";
import type { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import type { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
import type { getSeasonTimers } from "~/models/draft-timer";
export type QueueItem = typeof schema.draftQueue.$inferSelect;
export type AutodraftStatusEntry = {
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
};
type DraftPicks = Awaited<ReturnType<typeof getDraftPicksForSeason>>;
type Season = NonNullable<Awaited<ReturnType<typeof findSeasonWithTeamsAndLeague>>>;
type DraftSlots = Awaited<ReturnType<typeof findDraftSlotsBySeasonId>>;
type AutodraftSettings = Awaited<ReturnType<typeof getSeasonAutodraftSettings>>;
type Timers = Awaited<ReturnType<typeof getSeasonTimers>>;
interface UseDraftRoomStateParams {
draftPicks: DraftPicks;
season: Season;
userTeam: { id: string } | undefined;
isCommissioner: boolean;
autodraftSettings: AutodraftSettings;
timers: Timers;
draftSlots: DraftSlots;
userQueue: QueueItem[];
}
export function useDraftRoomState({
draftPicks,
season,
userTeam,
isCommissioner,
autodraftSettings,
timers,
draftSlots,
userQueue,
}: UseDraftRoomStateParams) {
const [picks, setPicks] = useState(draftPicks);
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
const [searchQuery, setSearchQuery] = useState("");
const [hideDrafted, setHideDrafted] = useState(true);
const [hideIneligible, setHideIneligible] = useState(true);
const [hideCompletedSports, setHideCompletedSports] = useState(false);
const [sportFilters, setSportFilters] = useState<string[]>([]);
const [queue, setQueue] = useState<QueueItem[]>(userQueue);
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
const [isDraftComplete, setIsDraftComplete] = useState(
season.status === "active" || season.status === "completed"
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
const stored = localStorage.getItem("draftSidebarCollapsed");
return stored ? JSON.parse(stored) : false;
});
const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("participants");
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">(
!userTeam && isCommissioner ? "board" : "available"
);
const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters");
const [boardSubTab, setBoardSubTab] = useState<"board" | "pick-list">("board");
const isMobile = useMediaQuery("(max-width: 767px)");
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, AutodraftStatusEntry>>(() => {
const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting) => {
status[setting.teamId] = {
isEnabled: setting.isEnabled,
mode: setting.mode,
queueOnly: setting.queueOnly,
};
});
return status;
});
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
const timersMap: Record<string, number> = {};
const initialTime = season.draftInitialTime || 120;
if (timers.length > 0) {
timers.forEach((timer) => {
timersMap[timer.teamId] = timer.timeRemaining;
});
} else {
draftSlots.forEach((slot) => {
timersMap[slot.team.id] = initialTime;
});
}
return timersMap;
});
const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false);
const [selectedPickSlot, setSelectedPickSlot] = useState<{
pickNumber: number;
teamId: string;
} | null>(null);
const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false);
const [replacePickSlot, setReplacePickSlot] = useState<{
pickNumber: number;
teamId: string;
oldParticipantId: string;
} | null>(null);
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null);
const [isRollingBack, setIsRollingBack] = useState(false);
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(null);
const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false);
const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState<string | null>(null);
const [timeBankAmount, setTimeBankAmount] = useState("1");
const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes");
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false);
return {
picks, setPicks,
currentPick, setCurrentPick,
searchQuery, setSearchQuery,
hideDrafted, setHideDrafted,
hideIneligible, setHideIneligible,
hideCompletedSports, setHideCompletedSports,
sportFilters, setSportFilters,
queue, setQueue,
isPaused, setIsPaused,
isDraftComplete, setIsDraftComplete,
sidebarCollapsed, setSidebarCollapsed,
activeTab, setActiveTab,
mobileTab, setMobileTab,
teamsSubTab, setTeamsSubTab,
boardSubTab, setBoardSubTab,
isMobile,
autodraftStatus, setAutodraftStatus,
connectedTeams, setConnectedTeams,
teamTimers, setTeamTimers,
forcePickDialogOpen, setForcePickDialogOpen,
selectedPickSlot, setSelectedPickSlot,
replacePickDialogOpen, setReplacePickDialogOpen,
replacePickSlot, setReplacePickSlot,
rollbackConfirmOpen, setRollbackConfirmOpen,
rollbackPickNumber, setRollbackPickNumber,
isRollingBack, setIsRollingBack,
timeBankDialogOpen, setTimeBankDialogOpen,
timeBankTeamId, setTimeBankTeamId,
commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen,
commissionerAutodraftTeamId, setCommissionerAutodraftTeamId,
timeBankAmount, setTimeBankAmount,
timeBankUnit, setTimeBankUnit,
timeBankDirection, setTimeBankDirection,
isAdjustingTimeBank, setIsAdjustingTimeBank,
};
}

View file

@ -0,0 +1,244 @@
import { useEffect, type MutableRefObject, type RefObject } from "react";
import { toast } from "sonner";
import { getTeamForPick } from "~/lib/draft-order";
import { getAutodraftLabel } from "~/components/AutodraftSettings";
import type { getDraftPicksForSeason } from "~/models/draft-pick";
import type { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import type { QueueItem, AutodraftStatusEntry } from "~/hooks/useDraftRoomState";
type DraftPicks = Awaited<ReturnType<typeof getDraftPicksForSeason>>;
type DraftSlots = Awaited<ReturnType<typeof findDraftSlotsBySeasonId>>;
type DraftPick = DraftPicks[number];
interface UseDraftSocketEventsParams {
on: (event: string, handler: (data: unknown) => void) => void;
off: (event: string, handler: (data: unknown) => void) => void;
socketVersion: number;
// Loader-derived, stable for lifetime of page load
userTeam: { id: string } | undefined;
draftSlots: DraftSlots;
leagueName: string;
// Refs from useDraftAuthRecovery
isRevalidatingRef: RefObject<boolean>;
pendingPicksDuringRevalidationRef: MutableRefObject<DraftPicks>;
pendingQueueMutationsRef: MutableRefObject<number>;
userAutodraftRef: MutableRefObject<{ isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
// Notification refs from the component
sendNotificationRef: MutableRefObject<(title: string, body: string) => void>;
notificationsModeRef: MutableRefObject<string>;
// State setters
setPicks: (fn: (prev: DraftPicks) => DraftPicks) => void;
setCurrentPick: (pick: number) => void;
setIsPaused: (paused: boolean) => void;
setIsDraftComplete: (complete: boolean) => void;
setAutodraftStatus: (fn: (prev: Record<string, AutodraftStatusEntry>) => Record<string, AutodraftStatusEntry>) => void;
setUserAutodraft: (value: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => void;
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
}
export function useDraftSocketEvents({
on,
off,
socketVersion,
userTeam,
draftSlots,
leagueName,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
userAutodraftRef,
sendNotificationRef,
notificationsModeRef,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setAutodraftStatus,
setUserAutodraft,
setConnectedTeams,
setQueue,
setTeamTimers,
}: UseDraftSocketEventsParams) {
useEffect(() => {
type PickMadePayload = {
pick: DraftPick & { team?: { id?: string; name?: string }; participant?: { name?: string } };
nextPickNumber: number;
isDraftComplete?: boolean;
};
const handlePickMade = (data: PickMadePayload) => {
if (isRevalidatingRef.current) {
pendingPicksDuringRevalidationRef.current.push(data.pick);
} else {
setPicks((prev) => [...prev, data.pick]);
setCurrentPick(data.nextPickNumber);
}
if (data.isDraftComplete) return;
const notifTitle = `${leagueName} Draft`;
const nextSlot = getTeamForPick(data.nextPickNumber, draftSlots);
const isNowMyTurn = !!(userTeam && nextSlot && nextSlot.team.id === userTeam.id);
if (isNowMyTurn) {
sendNotificationRef.current(notifTitle, `It's your turn to pick!`);
} else if (
notificationsModeRef.current === "all_picks" &&
data.pick?.team?.id !== userTeam?.id
) {
const pickerName = data.pick?.team?.name || "A team";
const participantName = data.pick?.participant?.name || "a participant";
sendNotificationRef.current(notifTitle, `${pickerName} picked ${participantName}`);
}
};
const handleTimerUpdate = (data: { teamId: string; timeRemaining: number; currentPickNumber: number | null }) => {
setTeamTimers((prev) => {
if (prev[data.teamId] === data.timeRemaining) return prev;
return { ...prev, [data.teamId]: data.timeRemaining };
});
if (data.currentPickNumber !== null) {
setCurrentPick(data.currentPickNumber);
}
};
const handleDraftPaused = () => setIsPaused(true);
const handleDraftResumed = () => setIsPaused(false);
const handleDraftCompleted = () => setIsDraftComplete(true);
const handleAutodraftUpdated = (data: {
teamId: string;
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
source?: "commissioner" | "user";
}) => {
setAutodraftStatus((prev) => ({
...prev,
[data.teamId]: { isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly },
}));
if (userTeam && data.teamId === userTeam.id) {
if (data.source === "commissioner") {
if (!data.isEnabled) {
toast.info("Commissioner turned off your autodraft");
} else {
toast.info(`Commissioner changed your autodraft to "${getAutodraftLabel(data.isEnabled, data.mode, data.queueOnly)}"`);
}
} else {
const prev = userAutodraftRef.current;
if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") {
toast.info("Autodraft disabled — your queue is empty");
}
}
setUserAutodraft({ isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly });
}
};
const handleTeamConnected = (data: { teamId: string }) => {
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
};
const handleTeamDisconnected = (data: { teamId: string }) => {
setConnectedTeams((prev) => {
const newSet = new Set(prev);
newSet.delete(data.teamId);
return newSet;
});
};
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
setConnectedTeams(() => new Set(data.teamIds));
};
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
setQueue((prev) => prev.filter((item) => item.participantId !== data.participantId));
};
const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => {
if (data.teamId !== userTeam?.id) return;
const removed = new Set(data.removedParticipantIds);
setQueue((prev) => prev.filter((item) => !removed.has(item.participantId)));
};
const handleQueueUpdated = (data: { queue: QueueItem[] }) => {
if (pendingQueueMutationsRef.current > 0) return;
setQueue(() => data.queue);
};
const handlePickReplaced = (data: { pickNumber: number; pick: DraftPick }) => {
setPicks((prev) => prev.map((p) => (p.pickNumber === data.pickNumber ? data.pick : p)));
};
const handleDraftRolledBack = (data: { pickNumber: number }) => {
setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber));
setCurrentPick(data.pickNumber);
setIsDraftComplete(false);
setIsPaused(false);
};
const handleDraftStateSync = (data: {
picks: DraftPicks;
currentPickNumber: number;
isPaused: boolean;
status: string;
timers?: Array<{ teamId: string; timeRemaining: number }>;
queue?: QueueItem[];
}) => {
if (!isRevalidatingRef.current) {
setPicks(() => data.picks);
setCurrentPick(data.currentPickNumber);
setIsPaused(data.isPaused);
setIsDraftComplete(data.status === "active" || data.status === "completed");
if (data.timers) {
setTeamTimers((prev) => {
const updated = { ...prev };
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
return updated;
});
}
if (data.queue) {
const q = data.queue;
setQueue(() => q);
}
}
};
on("pick-made", handlePickMade as (data: unknown) => void);
on("timer-update", handleTimerUpdate as (data: unknown) => void);
on("draft-paused", handleDraftPaused as (data: unknown) => void);
on("draft-resumed", handleDraftResumed as (data: unknown) => void);
on("draft-completed", handleDraftCompleted as (data: unknown) => void);
on("autodraft-updated", handleAutodraftUpdated as (data: unknown) => void);
on("team-connected", handleTeamConnected as (data: unknown) => void);
on("team-disconnected", handleTeamDisconnected as (data: unknown) => void);
on("connected-teams-list", handleConnectedTeamsList as (data: unknown) => void);
on("participant-removed-from-queues", handleParticipantRemovedFromQueues as (data: unknown) => void);
on("queue-eligibility-pruned", handleQueueEligibilityPruned as (data: unknown) => void);
on("queue-updated", handleQueueUpdated as (data: unknown) => void);
on("pick-replaced", handlePickReplaced as (data: unknown) => void);
on("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
on("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
return () => {
off("pick-made", handlePickMade as (data: unknown) => void);
off("timer-update", handleTimerUpdate as (data: unknown) => void);
off("draft-paused", handleDraftPaused as (data: unknown) => void);
off("draft-resumed", handleDraftResumed as (data: unknown) => void);
off("draft-completed", handleDraftCompleted as (data: unknown) => void);
off("autodraft-updated", handleAutodraftUpdated as (data: unknown) => void);
off("team-connected", handleTeamConnected as (data: unknown) => void);
off("team-disconnected", handleTeamDisconnected as (data: unknown) => void);
off("connected-teams-list", handleConnectedTeamsList as (data: unknown) => void);
off("participant-removed-from-queues", handleParticipantRemovedFromQueues as (data: unknown) => void);
off("queue-eligibility-pruned", handleQueueEligibilityPruned as (data: unknown) => void);
off("queue-updated", handleQueueUpdated as (data: unknown) => void);
off("pick-replaced", handlePickReplaced as (data: unknown) => void);
off("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
off("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, off, socketVersion]);
}

View file

@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from "react";
import { SLOT_WIDTH } from "~/components/scoring/BracketTreeView";
interface AnimState {
fromPage: number;
toPage: number;
dir: "left" | "right";
phase: "sliding" | "settling";
}
export function useRoundTransition(maxPage: number, defaultPage: number) {
const [page, setPage] = useState(defaultPage);
const [anim, setAnim] = useState<AnimState | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!anim || anim.phase !== "sliding" || !stripRef.current) return;
const el = stripRef.current;
const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0;
const raf = requestAnimationFrame(() => {
el.style.transition = "transform 200ms ease";
el.style.transform = `translateX(${finalX}px)`;
});
return () => cancelAnimationFrame(raf);
}, [anim]);
useEffect(() => {
if (!anim || anim.phase !== "settling") return;
const timer = setTimeout(() => setAnim(null), 520);
return () => clearTimeout(timer);
}, [anim]);
const navigate = (newPage: number) => {
if (anim || newPage < 0 || newPage > maxPage) return;
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
};
const handleTransitionEnd = () => {
if (!anim || anim.phase !== "sliding") return;
if (stripRef.current) {
stripRef.current.style.transition = "none";
stripRef.current.style.transform = "translateX(0)";
}
setPage(anim.toPage);
setAnim((prev) => (prev ? { ...prev, phase: "settling" } : null));
};
return { page, anim, stripRef, navigate, handleTransitionEnd };
}

56
app/lib/auth.ts Normal file
View file

@ -0,0 +1,56 @@
import { getAuth } from "@clerk/react-router/server";
import type { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { LoaderFunctionArgs } from "react-router";
type Db = ReturnType<typeof database>;
/**
* Verifies the request has an authenticated user who is either a commissioner
* or a team owner in the given league/season. Throws Response on failure.
*
* @param unauthMessage - 401 message when not logged in
* @param unauthorizedMessage - 403 message when logged in but not a member
*/
export async function requireLeagueAccess(
args: LoaderFunctionArgs,
{
leagueId,
seasonId,
db,
unauthMessage = "You must be logged in",
unauthorizedMessage = "You do not have access to this league",
}: {
leagueId: string;
seasonId: string;
db: Db;
unauthMessage?: string;
unauthorizedMessage?: string;
}
): Promise<void> {
const { userId } = await getAuth(args);
if (!userId) {
throw new Response(unauthMessage, { status: 401 });
}
const [commissioner, team] = await Promise.all([
db.query.commissioners.findFirst({
where: and(
eq(schema.commissioners.leagueId, leagueId),
eq(schema.commissioners.userId, userId)
),
}),
db.query.teams.findFirst({
where: and(
eq(schema.teams.seasonId, seasonId),
eq(schema.teams.ownerId, userId)
),
}),
]);
if (!commissioner && !team) {
throw new Response(unauthorizedMessage, { status: 403 });
}
}

20
app/lib/avatar-colors.ts Normal file
View file

@ -0,0 +1,20 @@
export const AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
export function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) & 0xffff;
}
return hash;
}
export function avatarColor(input: string): string {
return AVATAR_COLORS[hashString(input) % AVATAR_COLORS.length];
}

69
app/lib/corona-states.ts Normal file
View file

@ -0,0 +1,69 @@
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
export type CoronaState =
| { type: "eliminated"; points: 0 }
| { type: "pending" }
| { type: "scored"; brightness: number; points: number };
interface PickEntry {
participant: { id: string; sportsSeasonId: string };
scoringPattern: string | null;
}
interface ResultEntry {
participantId: string;
finalPosition: number | null;
isPartialScore: boolean | null;
}
interface ScoringRules {
pointsFor1st: number;
pointsFor2nd: number;
pointsFor3rd: number;
pointsFor4th: number;
pointsFor5th: number;
pointsFor6th: number;
pointsFor7th: number;
pointsFor8th: number;
}
export function computeCoronaStates(
picks: PickEntry[],
resultByParticipant: Map<string, ResultEntry>,
bracketTemplateBySportsSeason: Map<string, string | null>,
scoringRules: ScoringRules,
maxPoints: number,
): Record<string, CoronaState> {
const coronaStates: Record<string, CoronaState> = {};
for (const pick of picks) {
const result = resultByParticipant.get(pick.participant.id);
if (!result || result.finalPosition === null) {
coronaStates[pick.participant.id] = { type: "pending" };
continue;
}
if (result.finalPosition === 0 && !result.isPartialScore) {
coronaStates[pick.participant.id] = { type: "eliminated", points: 0 };
continue;
}
if (result.finalPosition > 0) {
const isBracket = pick.scoringPattern === "playoff_bracket";
const templateId = isBracket
? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null)
: null;
const points = isBracket
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
: calculateFantasyPoints(result.finalPosition, scoringRules);
const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
coronaStates[pick.participant.id] = { type: "scored", brightness, points };
continue;
}
coronaStates[pick.participant.id] = { type: "pending" };
}
return coronaStates;
}

43
app/lib/landing-data.ts Normal file
View file

@ -0,0 +1,43 @@
import { Users, Shuffle, Trophy } from "lucide-react";
export const SPORTS = [
"Football", "Baseball", "Hockey", "Basketball", "Soccer", "Tennis", "Golf",
"eSports", "Formula 1", "Aussie Rules", "Lacrosse", "Darts", "College Football", "Snooker", "March Madness",
];
export const HOW_IT_WORKS_STEPS = [
{
icon: Users,
title: "Create a League",
body: "Start a league, pick your sports (we can help), and invite your friends. It takes 30 seconds to set the stage.",
},
{
icon: Shuffle,
title: "Draft Your Teams",
body: "Pick teams from all the sports in order to build a superteam to win you bragging rights! Draft your way, slow or fast, with standard timers or high-pressure chess clocks.",
},
{
icon: Trophy,
title: "Watch & Win",
body: "Your teams earn points for how well they perform in the playoffs. Follow your rooting interests along the way, and gloat over your leaguemates when they fall.",
},
];
export const FEATURES = [
{
title: "Dozens of Sports and Counting",
body: "Draft from a massive selection of sports including major US leagues, college athletics, and global niches like professional darts or snooker. We are constantly adding new sports to ensure your league has competitive action throughout the entire year.",
},
{
title: "The Smart Draft Room",
body: "Our interface supports any pace. Use a tactical chess clock with Fischer increments to reward fast decisions, or run a multi-day slow draft with customizable overnight pauses. The system ensures no one is forced to make a pick in the middle of the night.",
},
{
title: "Your Master Calendar",
body: "Stop switching between different apps to track your roster. Every game, match, and tournament for your specific teams is synced into a single view. You will always know exactly when your points are on the line without any manual tracking.",
},
{
title: "Zero Maintenance",
body: "Brackt is a draft and done experience. Once the draft is finished, your work is over because we handle all scoring and rankings automatically. You can even paste a Discord webhook URL to get live scoring updates sent directly to your group chat.",
},
];

View file

@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── DB mock ────────────────────────────────────────────────────────────────
const DEFAULT_SEASON = {
id: "season-1",
pointsFor1st: 100, pointsFor2nd: 75, pointsFor3rd: 50,
pointsFor4th: 40, pointsFor5th: 30, pointsFor6th: 20,
pointsFor7th: 10, pointsFor8th: 5,
};
type PickRow = {
participant: {
id: string;
name: string;
sportsSeasonId: string;
sportsSeason: { id: string; scoringPattern: string };
results: { finalPosition: number | null }[];
};
};
interface MakeDbOpts {
season?: typeof DEFAULT_SEASON | null;
picks?: PickRow[];
scoringEvents?: { sportsSeasonId: string; bracketTemplateId: string | null }[];
qualifyingTotals?: { participantId: string; totalQualifyingPoints: string }[];
}
function makeDb(opts: MakeDbOpts = {}) {
const {
season = DEFAULT_SEASON,
picks = [],
scoringEvents = [],
qualifyingTotals = [],
} = opts;
return {
query: {
seasons: {
findFirst: vi.fn().mockResolvedValue(season),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue(picks),
},
scoringEvents: {
findMany: vi.fn().mockResolvedValue(scoringEvents),
},
participantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue(qualifyingTotals),
},
},
} as any;
}
import { getDraftedParticipantsWithPoints } from "../draft-pick";
function makePick(overrides: {
id?: string;
name?: string;
sportsSeasonId?: string;
scoringPattern?: string;
finalPosition?: number | null;
}): PickRow {
const {
id = "p-1",
name = "Participant One",
sportsSeasonId = "ss-1",
scoringPattern = "playoff_bracket",
finalPosition = null,
} = overrides;
return {
participant: {
id,
name,
sportsSeasonId,
sportsSeason: { id: sportsSeasonId, scoringPattern },
results: finalPosition !== null ? [{ finalPosition }] : [],
},
};
}
describe("getDraftedParticipantsWithPoints", () => {
beforeEach(() => vi.clearAllMocks());
it("returns empty map when season not found", async () => {
const db = makeDb({ season: null });
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.size).toBe(0);
});
it("returns empty map when no picks", async () => {
const db = makeDb({ picks: [] });
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.size).toBe(0);
});
describe("playoff_bracket pattern", () => {
it("returns earnedPoints based on finalPosition and scoring rules", async () => {
const db = makeDb({
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: 1 })],
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
const ssEntry = result.get("ss-1");
expect(ssEntry).toBeDefined();
expect(ssEntry?.[0]).toMatchObject({
id: "p-1",
name: "Participant One",
earnedPoints: 100, // pointsFor1st
currentQP: null,
});
});
it("returns null earnedPoints when no finalPosition yet", async () => {
const db = makeDb({
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: null })],
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({ earnedPoints: null, currentQP: null });
});
it("groups multiple picks by sportsSeasonId", async () => {
const db = makeDb({
picks: [
makePick({ id: "p-1", name: "Alice", sportsSeasonId: "ss-1", scoringPattern: "playoff_bracket", finalPosition: 1 }),
makePick({ id: "p-2", name: "Bob", sportsSeasonId: "ss-1", scoringPattern: "playoff_bracket", finalPosition: 2 }),
makePick({ id: "p-3", name: "Carol", sportsSeasonId: "ss-2", scoringPattern: "playoff_bracket", finalPosition: 1 }),
],
scoringEvents: [
{ sportsSeasonId: "ss-1", bracketTemplateId: null },
{ sportsSeasonId: "ss-2", bracketTemplateId: null },
],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")).toHaveLength(2);
expect(result.get("ss-2")).toHaveLength(1);
});
});
describe("qualifying_points pattern — active (no finalPosition)", () => {
it("returns currentQP from participantQualifyingTotals", async () => {
const db = makeDb({
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: null })],
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "42.5" }],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({
earnedPoints: null,
currentQP: 42.5,
});
});
it("returns null currentQP when participant has no QP total row", async () => {
const db = makeDb({
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: null })],
qualifyingTotals: [],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({ earnedPoints: null, currentQP: null });
});
});
describe("qualifying_points pattern — finalized (has finalPosition)", () => {
it("returns earnedPoints via calculateFantasyPoints when finalPosition set", async () => {
const db = makeDb({
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: 2 })],
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "99" }],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({
earnedPoints: 75, // pointsFor2nd
currentQP: null,
});
});
});
describe("season_standings pattern", () => {
it("returns earnedPoints via calculateFantasyPoints", async () => {
const db = makeDb({
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "season_standings", finalPosition: 3 })],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({
earnedPoints: 50, // pointsFor3rd
currentQP: null,
});
});
});
it("does not query scoringEvents when no playoff_bracket picks", async () => {
const db = makeDb({
picks: [makePick({ scoringPattern: "season_standings", finalPosition: 1 })],
});
await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(db.query.scoringEvents.findMany).not.toHaveBeenCalled();
});
it("does not query participantQualifyingTotals when no qualifying_points picks", async () => {
const db = makeDb({
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: null })],
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
});
await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(db.query.participantQualifyingTotals.findMany).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,360 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn() },
}));
// ── DB mock helpers ────────────────────────────────────────────────────────
function makeInsertChain() {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
const insert = vi.fn().mockReturnValue({ values });
return { insert, values, onConflictDoUpdate };
}
interface MakeDbOpts {
sportsSeason?: { sport: { name: string } } | null;
seasonSports?: { seasonId: string }[];
picks?: { teamId: string; seasonId: string }[];
seasons?: {
id: string;
pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number;
pointsFor4th: number; pointsFor5th: number; pointsFor6th: number;
pointsFor7th: number; pointsFor8th: number;
}[];
scoreEventRows?: {
id: string; teamId: string; teamName?: string;
scoringEventId: string | null; scoringEventName: string | null;
sportName: string | null; pointsDelta: string; occurredAt: Date;
participantIds: string[];
team: { id: string; name: string };
}[];
participantRows?: { id: string; name: string }[];
}
function makeDb(opts: MakeDbOpts = {}) {
const {
sportsSeason = null,
seasonSports = [],
picks = [],
seasons = [],
scoreEventRows = [],
participantRows = [],
} = opts;
const chain = makeInsertChain();
return {
db: {
insert: chain.insert,
query: {
sportsSeasons: {
findFirst: vi.fn().mockResolvedValue(sportsSeason),
},
seasonSports: {
findMany: vi.fn().mockResolvedValue(seasonSports),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue(picks),
},
seasons: {
findMany: vi.fn().mockResolvedValue(seasons),
},
teamScoreEvents: {
findMany: vi.fn().mockResolvedValue(scoreEventRows),
},
participants: {
findMany: vi.fn().mockResolvedValue(participantRows),
},
},
} as any,
chain,
};
}
import { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events";
const BASE_PARAMS = {
teamId: "team-1",
seasonId: "season-1",
scoringEventId: "event-1",
scoringEventName: "Round of 16",
sportName: "Darts",
participantIds: ["p-1"],
pointsDelta: 10,
};
describe("recordTeamScoreEvent", () => {
beforeEach(() => vi.clearAllMocks());
it("uses matchId-based conflict target when matchId provided", async () => {
const { db, chain } = makeDb();
await recordTeamScoreEvent({ ...BASE_PARAMS, matchId: "match-1" }, db);
expect(chain.insert).toHaveBeenCalled();
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ matchId: "match-1", pointsDelta: "10" })
);
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
expect(conflictArg.target).toHaveLength(3);
// targetWhere should reference matchId IS NOT NULL
expect(conflictArg.targetWhere).toBeDefined();
expect(conflictArg.set).toMatchObject({ pointsDelta: "10", participantIds: ["p-1"] });
});
it("uses scoringEventId-based conflict target when no matchId", async () => {
const { db, chain } = makeDb();
await recordTeamScoreEvent(BASE_PARAMS, db);
expect(chain.insert).toHaveBeenCalled();
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ matchId: null })
);
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
expect(conflictArg.target).toHaveLength(3);
expect(conflictArg.targetWhere).toBeDefined();
});
it("stores pointsDelta as string", async () => {
const { db, chain } = makeDb();
await recordTeamScoreEvent({ ...BASE_PARAMS, pointsDelta: 42 }, db);
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ pointsDelta: "42" }));
});
it("defaults matchId to null when not provided", async () => {
const { db, chain } = makeDb();
await recordTeamScoreEvent(BASE_PARAMS, db);
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ matchId: null }));
});
});
describe("recordMatchScoreEvents", () => {
beforeEach(() => vi.clearAllMocks());
const BASE_EVENT = {
participantId: "p-1",
sportsSeasonId: "ss-1",
oldFloor: 0,
newFloor: 2,
bracketTemplateId: null as string | null,
matchId: "match-1",
eventId: "event-1",
eventName: "Semifinals",
};
const SEASON_ROW = {
id: "season-1",
pointsFor1st: 100, pointsFor2nd: 75, pointsFor3rd: 50,
pointsFor4th: 40, pointsFor5th: 30, pointsFor6th: 20,
pointsFor7th: 10, pointsFor8th: 5,
};
it("returns early when no fantasy seasons use this sports season", async () => {
const { db, chain } = makeDb({
sportsSeason: { sport: { name: "Darts" } },
seasonSports: [],
});
await recordMatchScoreEvents(BASE_EVENT, db);
expect(chain.insert).not.toHaveBeenCalled();
});
it("returns early when no draft picks found for participant", async () => {
const { db, chain } = makeDb({
sportsSeason: { sport: { name: "Darts" } },
seasonSports: [{ seasonId: "season-1" }],
picks: [],
seasons: [SEASON_ROW],
});
await recordMatchScoreEvents(BASE_EVENT, db);
expect(chain.insert).not.toHaveBeenCalled();
});
it("skips seasons where team did not draft the participant", async () => {
const { db, chain } = makeDb({
sportsSeason: { sport: { name: "Darts" } },
seasonSports: [{ seasonId: "season-1" }, { seasonId: "season-2" }],
picks: [{ teamId: "team-1", seasonId: "season-1" }], // only season-1 has pick
seasons: [SEASON_ROW, { ...SEASON_ROW, id: "season-2" }],
});
await recordMatchScoreEvents(BASE_EVENT, db);
expect(chain.insert).toHaveBeenCalledTimes(1);
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ seasonId: "season-1" }));
});
it("skips seasons where point delta is zero or negative", async () => {
const { db, chain } = makeDb({
sportsSeason: { sport: { name: "Darts" } },
seasonSports: [{ seasonId: "season-1" }],
picks: [{ teamId: "team-1", seasonId: "season-1" }],
seasons: [{ ...SEASON_ROW, pointsFor1st: 0, pointsFor2nd: 0 }], // all zeros → delta=0
});
// With all-zero scoring rules, positions map to 0 points → delta = 0 → skip
await recordMatchScoreEvents({ ...BASE_EVENT, oldFloor: 1, newFloor: 2 }, db);
expect(chain.insert).not.toHaveBeenCalled();
});
it("records score event with correct teamId, seasonId, and matchId", async () => {
const { db, chain } = makeDb({
sportsSeason: { sport: { name: "Darts" } },
seasonSports: [{ seasonId: "season-1" }],
picks: [{ teamId: "team-1", seasonId: "season-1" }],
seasons: [SEASON_ROW],
});
await recordMatchScoreEvents(BASE_EVENT, db);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({
teamId: "team-1",
seasonId: "season-1",
matchId: "match-1",
sportName: "Darts",
participantIds: ["p-1"],
})
);
});
it("uses sport name from sportsSeasons query", async () => {
const { db, chain } = makeDb({
sportsSeason: { sport: { name: "Snooker" } },
seasonSports: [{ seasonId: "season-1" }],
picks: [{ teamId: "team-1", seasonId: "season-1" }],
seasons: [SEASON_ROW],
});
await recordMatchScoreEvents(BASE_EVENT, db);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ sportName: "Snooker" })
);
});
it("handles null sportsSeason gracefully (sportName = null)", async () => {
const { db, chain } = makeDb({
sportsSeason: null,
seasonSports: [{ seasonId: "season-1" }],
picks: [{ teamId: "team-1", seasonId: "season-1" }],
seasons: [SEASON_ROW],
});
await recordMatchScoreEvents(BASE_EVENT, db);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ sportName: null })
);
});
});
describe("getRecentTeamScoreEvents", () => {
beforeEach(() => vi.clearAllMocks());
it("returns empty array when no rows found", async () => {
const { db } = makeDb({ scoreEventRows: [] });
const result = await getRecentTeamScoreEvents("season-1", 10, db);
expect(result).toEqual([]);
});
it("does not query participants when rows array is empty", async () => {
const { db } = makeDb({ scoreEventRows: [] });
await getRecentTeamScoreEvents("season-1", 10, db);
expect(db.query.participants.findMany).not.toHaveBeenCalled();
});
it("returns mapped entries with resolved participant names", async () => {
const occurredAt = new Date("2024-01-15");
const { db } = makeDb({
scoreEventRows: [
{
id: "tse-1",
teamId: "team-1",
scoringEventId: "event-1",
scoringEventName: "Semifinals",
sportName: "Darts",
pointsDelta: "75",
occurredAt,
participantIds: ["p-1", "p-2"],
team: { id: "team-1", name: "Team Alpha" },
},
],
participantRows: [
{ id: "p-1", name: "Luke Littler" },
{ id: "p-2", name: "Michael van Gerwen" },
],
});
const result = await getRecentTeamScoreEvents("season-1", 10, db);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: "tse-1",
teamId: "team-1",
teamName: "Team Alpha",
scoringEventId: "event-1",
scoringEventName: "Semifinals",
sportName: "Darts",
pointsDelta: "75",
occurredAt,
});
expect(result[0].participants).toEqual([
{ id: "p-1", name: "Luke Littler" },
{ id: "p-2", name: "Michael van Gerwen" },
]);
});
it("omits participants whose names cannot be resolved", async () => {
const { db } = makeDb({
scoreEventRows: [
{
id: "tse-1",
teamId: "team-1",
scoringEventId: "event-1",
scoringEventName: null,
sportName: null,
pointsDelta: "50",
occurredAt: new Date(),
participantIds: ["p-1", "p-missing"],
team: { id: "team-1", name: "Team Beta" },
},
],
participantRows: [{ id: "p-1", name: "Known Player" }],
});
const result = await getRecentTeamScoreEvents("season-1", 10, db);
expect(result[0].participants).toEqual([{ id: "p-1", name: "Known Player" }]);
});
it("handles null participantIds gracefully", async () => {
const { db } = makeDb({
scoreEventRows: [
{
id: "tse-1",
teamId: "team-1",
scoringEventId: "event-1",
scoringEventName: null,
sportName: null,
pointsDelta: "10",
occurredAt: new Date(),
participantIds: null as any,
team: { id: "team-1", name: "Team Gamma" },
},
],
participantRows: [],
});
const result = await getRecentTeamScoreEvents("season-1", 10, db);
expect(result[0].participants).toEqual([]);
});
});

View file

@ -0,0 +1,12 @@
import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type AutodraftSetting = typeof schema.autodraftSettings.$inferSelect;
export async function getSeasonAutodraftSettings(seasonId: string): Promise<AutodraftSetting[]> {
const db = database();
return await db.query.autodraftSettings.findMany({
where: eq(schema.autodraftSettings.seasonId, seasonId),
});
}

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
import { eq, and, inArray, asc } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
export async function createDraftPick(data: {
@ -327,3 +327,25 @@ export async function getTeamDraftPicksWithSports(teamId: string, seasonId: stri
},
}));
}
export async function getDraftPicksForSeason(seasonId: string) {
const db = database();
return await db
.select({
id: schema.draftPicks.id,
pickNumber: schema.draftPicks.pickNumber,
round: schema.draftPicks.round,
pickInRound: schema.draftPicks.pickInRound,
timeUsed: schema.draftPicks.timeUsed,
team: schema.teams,
participant: schema.participants,
sport: schema.sports,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(schema.participants, eq(schema.draftPicks.participantId, schema.participants.id))
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(asc(schema.draftPicks.pickNumber));
}

View file

@ -1,4 +1,4 @@
import { eq, inArray, count, and, sql } from "drizzle-orm";
import { eq, inArray, count, and, sql, asc, desc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -58,7 +58,7 @@ export async function findParticipantsBySportsSeasonId(sportsSeasonId: string):
const db = database();
return await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
orderBy: (participants, { asc }) => [asc(participants.name)],
orderBy: (participants, { asc: orderAsc }) => [orderAsc(participants.name)],
});
}
@ -170,3 +170,31 @@ export async function getParticipantsForSeasonWithSports(seasonId: string, provi
return participants;
}
export async function getDraftParticipants(seasonId: string) {
const db = database();
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
if (sportsSeasonIds.length === 0) return [];
return await db
.select({
id: schema.participants.id,
name: schema.participants.name,
vorpValue: schema.participants.vorpValue,
sport: schema.sports,
})
.from(schema.participants)
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(
sportsSeasonIds.length === 1
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
)
.orderBy(desc(schema.participants.vorpValue), asc(schema.participants.name));
}

View file

@ -11,6 +11,10 @@ import { getUserDisplayName } from "~/models/user";
import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result";
import { getQPForPlacement, recalculateParticipantQP, getQPStandings, updateFinalRankings } from "./qualifying-points";
import { getParticipantEV } from "./participant-expected-value";
import { calculateEV } from "~/services/ev-calculator";
/**
* Core scoring calculation engine
@ -285,18 +289,34 @@ export async function processPlayoffEvent(
throw new Error("Finals match is not complete");
}
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, config.winnerPosition ?? 1, db);
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
const loserOldFloor = await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
if (loserOldFloor !== null) {
await recordMatchScoreEvents(
{ participantId: finalMatch.loserId, sportsSeasonId: event.sportsSeasonId, oldFloor: loserOldFloor,
newFloor: config.loserPosition, bracketTemplateId: event.bracketTemplateId ?? null,
matchId: finalMatch.id, eventId, eventName: event.name },
db
);
}
} else {
// All other scoring rounds: assign loser's final (or provisional) position.
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
const loserOldFloor = await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
config.loserPosition,
db,
config.loserIsPartial
);
if (loserOldFloor !== null) {
await recordMatchScoreEvents(
{ participantId: match.loserId, sportsSeasonId: event.sportsSeasonId, oldFloor: loserOldFloor,
newFloor: config.loserPosition, bracketTemplateId: event.bracketTemplateId ?? null,
matchId: match.id, eventId, eventName: event.name },
db
);
}
}
}
}
@ -414,7 +434,14 @@ export async function processMatchResult(
}
} else if (config.winnerFloor === null) {
// Finalization round: winner and loser both get their exact positions.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
const loserOldFloor = await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
if (loserOldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: loserId, sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
const winnerPosition = config.winnerPosition ?? 1;
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, winnerPosition, db);
if (oldFloor !== null && matchId && eventId) {
@ -426,7 +453,14 @@ export async function processMatchResult(
}
} else {
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
const loserOldFloor = await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
if (loserOldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: loserId, sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
if (oldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
@ -556,10 +590,6 @@ export async function processQualifyingEvent(
): Promise<void> {
const db = providedDb || database();
// Import qualifying points functions
const { getEventResults } = await import("./event-result");
const { getQPForPlacement, recalculateParticipantQP } = await import("./qualifying-points");
// Get the event
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
@ -654,9 +684,6 @@ export async function finalizeQualifyingPoints(
): Promise<void> {
const db = providedDb || database();
// Import qualifying points functions
const { getQPStandings, updateFinalRankings } = await import("./qualifying-points");
// Get all participants ranked by total QP (highest first)
const standings = await getQPStandings(sportsSeasonId, db);
@ -1092,10 +1119,6 @@ export async function calculateTeamProjectedScore(
// Get EVs for unfinished participants
let evSum = 0;
if (unfinishedParticipants.length > 0) {
// Import the participant EV model
const { getParticipantEV } = await import("./participant-expected-value");
const { calculateEV } = await import("~/services/ev-calculator");
for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) {
const ev = await getParticipantEV(participantId, sportsSeasonId);

View file

@ -130,6 +130,17 @@ export async function deleteSeason(id: string): Promise<void> {
await db.delete(schema.seasons).where(eq(schema.seasons.id, id));
}
export async function findSeasonWithTeamsAndLeague(seasonId: string) {
const db = database();
return await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
with: {
league: true,
teams: true,
},
});
}
export async function findSeasonWithSportsSeasons(
id: string
): Promise<SeasonWithSportsSeasons | undefined> {

View file

@ -4,6 +4,8 @@ import { eq, and } from "drizzle-orm";
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
import { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value";
import { calculateEV } from "~/services/ev-calculator";
// Re-export types from shared types file
export type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
@ -179,8 +181,6 @@ export async function getTeamScoreBreakdown(
let projectedPoints: number | null = null;
const getEV = async () => {
const { getParticipantEV } = await import("./participant-expected-value");
const { calculateEV } = await import("~/services/ev-calculator");
const ev = await getParticipantEV(pick.participant.id, pick.participant.sportsSeasonId);
if (!ev) return null;
return calculateEV(

View file

@ -8,6 +8,7 @@ import { findAllSportsSeasons } from "~/models/sports-season";
import { findAllSeasonTemplates } from "~/models/season-template";
import { countAllParticipants } from "~/models/participant";
import { countAllParticipantEVs } from "~/models/participant-expected-value";
import { importSportsDataFromJSON } from "~/utils/sports-data-sync.server";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import {
@ -68,9 +69,6 @@ export async function action({ request }: Route.ActionArgs) {
}
try {
const { importSportsDataFromJSON } = await import(
"~/utils/sports-data-sync.server"
);
const result = await importSportsDataFromJSON(fileData, mode);
return {
success: true,

View file

@ -6,6 +6,7 @@ import { getSocketIO } from "~/server/socket";
import { isUserAdminByClerkId } from "~/models/user";
import { getTeamForPick } from "~/lib/draft-order";
import { logger } from "~/lib/logger";
import { executeAutoPick } from "~/models/draft-utils";
import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) {
@ -108,7 +109,7 @@ export async function action(args: ActionFunctionArgs) {
// If commissioner enables autodraft for the team currently on the clock, fire immediately
if (isEnabled && isActingAsCommissioner) {
import("~/models/draft-utils").then(async ({ executeAutoPick }) => {
Promise.resolve().then(async () => {
const freshSeason = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});

View file

@ -1,5 +1,6 @@
import { Link } from "react-router";
import { Button } from "app/components/ui/button";
import { ScoringPointsTable, QualifyingPointsTable } from "~/components/marketing/ScoringTables";
export function meta() {
return [
@ -73,58 +74,7 @@ export default function HowToPlay() {
can adjust point values before the draft, but here's what a typical
league looks like:
</p>
<div className="bg-muted rounded-lg overflow-hidden mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st Place</td>
<td className="p-3 text-right font-semibold text-foreground">
100
</td>
</tr>
<tr>
<td className="p-3">2nd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
70
</td>
</tr>
<tr>
<td className="p-3">3rd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
50
</td>
</tr>
<tr>
<td className="p-3">4th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
40
</td>
</tr>
<tr>
<td className="p-3">5th6th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
25
</td>
</tr>
<tr>
<td className="p-3">7th8th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
15
</td>
</tr>
</tbody>
</table>
</div>
<ScoringPointsTable className="mb-4" />
<p className="text-sm text-muted-foreground">
If nobody in your league drafted a team that finishes in the top 8,
those points aren't awarded to anyone in your league. In other
@ -149,70 +99,7 @@ export default function HowToPlay() {
</span>{" "}
at each one:
</p>
<div className="bg-muted rounded-lg overflow-hidden mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Major Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Qualifying Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st</td>
<td className="p-3 text-right font-semibold text-foreground">
20 QP
</td>
</tr>
<tr>
<td className="p-3">2nd</td>
<td className="p-3 text-right font-semibold text-foreground">
14 QP
</td>
</tr>
<tr>
<td className="p-3">3rd</td>
<td className="p-3 text-right font-semibold text-foreground">
10 QP
</td>
</tr>
<tr>
<td className="p-3">4th</td>
<td className="p-3 text-right font-semibold text-foreground">
8 QP
</td>
</tr>
<tr>
<td className="p-3">5th6th</td>
<td className="p-3 text-right font-semibold text-foreground">
5 QP
</td>
</tr>
<tr>
<td className="p-3">7th8th</td>
<td className="p-3 text-right font-semibold text-foreground">
3 QP
</td>
</tr>
<tr>
<td className="p-3">9th12th</td>
<td className="p-3 text-right font-semibold text-foreground">
2 QP
</td>
</tr>
<tr>
<td className="p-3">13th16th</td>
<td className="p-3 text-right font-semibold text-foreground">
1 QP
</td>
</tr>
</tbody>
</table>
</div>
<QualifyingPointsTable className="mb-4" />
<p className="text-muted-foreground">
QP don't go straight to your score they determine the final
ranking within that sport. Once all the majors are done, total QP

View file

@ -1,16 +1,16 @@
import { useLoaderData, Link } from "react-router";
import { eq, asc, and, inArray } from "drizzle-orm";
import { getAuth } from "@clerk/react-router/server";
import { eq, asc, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { requireLeagueAccess } from "~/lib/auth";
import { DraftGrid } from "~/components/DraftGrid";
import { useDraftSocket } from "~/hooks/useDraftSocket";
import { useState, useEffect } from "react";
import { buildOwnerMap } from "~/lib/owner-map";
import { Button } from "~/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
import logomarkUrl from "../../../public/logomark.svg?url";
import { computeCoronaStates } from "~/lib/corona-states";
import type { CoronaState } from "~/components/draft/DraftPickCell";
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
@ -47,32 +47,13 @@ export async function loader(args: Route.LoaderArgs) {
// Check access: public boards are accessible to everyone without auth
if (!season.league.isPublicDraftBoard) {
// Not public - check if the user is a league member or commissioner
const { userId } = await getAuth(args);
if (!userId) {
throw new Response("This draft board is not public", { status: 403 });
}
// Check if user is a commissioner
const isCommissioner = await db.query.commissioners.findFirst({
where: and(
eq(schema.commissioners.leagueId, season.leagueId),
eq(schema.commissioners.userId, userId)
),
await requireLeagueAccess(args, {
leagueId: season.leagueId,
seasonId,
db,
unauthMessage: "This draft board is not public",
unauthorizedMessage: "You don't have access to this draft board",
});
// Check if user has a team in this season
const hasTeam = await db.query.teams.findFirst({
where: and(
eq(schema.teams.seasonId, seasonId),
eq(schema.teams.ownerId, userId)
),
});
if (!isCommissioner && !hasTeam) {
throw new Response("You don't have access to this draft board", { status: 403 });
}
}
// Get draft slots (draft order)
@ -115,13 +96,11 @@ export async function loader(args: Route.LoaderArgs) {
const ownerMap = await buildOwnerMap(draftSlots);
const coronaStates: Record<string, CoronaState> = {};
let coronaStates: Record<string, CoronaState> = {};
if (draftPicks.length > 0) {
const participantIds = draftPicks.map((p) => p.participant.id);
const sportsSeasonIds = [
...new Set(draftPicks.map((p) => p.participant.sportsSeasonId)),
];
const sportsSeasonIds = [...new Set(draftPicks.map((p) => p.participant.sportsSeasonId))];
const results = await db
.select({
@ -133,11 +112,7 @@ export async function loader(args: Route.LoaderArgs) {
.from(schema.participantResults)
.where(inArray(schema.participantResults.participantId, participantIds));
const resultByParticipant = new Map(
results.map((r) => [r.participantId, r])
);
const maxPoints = season.pointsFor1st;
const resultByParticipant = new Map(results.map((r) => [r.participantId, r]));
const bracketTemplateBySportsSeason = new Map<string, string | null>();
if (sportsSeasonIds.length > 0) {
@ -150,10 +125,7 @@ export async function loader(args: Route.LoaderArgs) {
.where(inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds));
for (const ev of events) {
if (!bracketTemplateBySportsSeason.has(ev.sportsSeasonId)) {
bracketTemplateBySportsSeason.set(
ev.sportsSeasonId,
ev.bracketTemplateId
);
bracketTemplateBySportsSeason.set(ev.sportsSeasonId, ev.bracketTemplateId);
}
}
}
@ -169,46 +141,13 @@ export async function loader(args: Route.LoaderArgs) {
pointsFor8th: season.pointsFor8th,
};
for (const pick of draftPicks) {
const result = resultByParticipant.get(pick.participant.id);
if (!result || result.finalPosition === null) {
coronaStates[pick.participant.id] = { type: "pending" };
continue;
}
if (result.finalPosition === 0 && !result.isPartialScore) {
coronaStates[pick.participant.id] = { type: "eliminated", points: 0 };
continue;
}
if (result.finalPosition > 0) {
const isBracket =
pick.scoringPattern === "playoff_bracket";
const templateId = isBracket
? bracketTemplateBySportsSeason.get(
pick.participant.sportsSeasonId
) ?? null
: null;
const points = isBracket
? calculateBracketPoints(
result.finalPosition,
scoringRules,
templateId
)
: calculateFantasyPoints(result.finalPosition, scoringRules);
const brightness =
maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
coronaStates[pick.participant.id] = {
type: "scored",
brightness,
points,
};
continue;
}
coronaStates[pick.participant.id] = { type: "pending" };
}
coronaStates = computeCoronaStates(
draftPicks,
resultByParticipant,
bracketTemplateBySportsSeason,
scoringRules,
season.pointsFor1st,
);
}
return {

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
import { useLoaderData, Link } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { requireLeagueAccess } from "~/lib/auth";
import { Button } from "~/components/ui/button";
import { logger } from "~/lib/logger";
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
@ -21,7 +21,6 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export async function loader(args: Route.LoaderArgs) {
try {
const { userId } = await getAuth(args);
const { params } = args;
const { leagueId, seasonId } = params;
@ -46,34 +45,14 @@ export async function loader(args: Route.LoaderArgs) {
}
// Check access
if (!userId) {
throw new Response("You must be logged in to view standings", {
status: 401,
});
}
// Check if user is a commissioner
const isUserCommissioner = await db.query.commissioners.findFirst({
where: and(
eq(schema.commissioners.leagueId, leagueId),
eq(schema.commissioners.userId, userId)
),
await requireLeagueAccess(args, {
leagueId,
seasonId,
db,
unauthMessage: "You must be logged in to view standings",
unauthorizedMessage: "You do not have access to this league",
});
// Check if user has a team in this season
const hasTeam = await db.query.teams.findFirst({
where: and(
eq(schema.teams.seasonId, seasonId),
eq(schema.teams.ownerId, userId)
),
});
if (!isUserCommissioner && !hasTeam) {
throw new Response("You do not have access to this league", {
status: 403,
});
}
// Fetch standings, teams (for owner lookup), and independent data in parallel
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage, recentScoreEvents] =
await Promise.all([

View file

@ -1,3 +1,5 @@
import { ScoringPointsTable, QualifyingPointsTable } from "~/components/marketing/ScoringTables";
export function meta() {
return [
{ title: "Rules - Brackt" },
@ -69,58 +71,7 @@ export default function Rules() {
begins. To find out how a specific sport determines its final
standings, visit the corresponding sport page.
</p>
<div className="bg-muted rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st Place</td>
<td className="p-3 text-right font-semibold text-foreground">
100
</td>
</tr>
<tr>
<td className="p-3">2nd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
70
</td>
</tr>
<tr>
<td className="p-3">3rd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
50
</td>
</tr>
<tr>
<td className="p-3">4th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
40
</td>
</tr>
<tr>
<td className="p-3">5th6th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
25
</td>
</tr>
<tr>
<td className="p-3">7th8th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
15
</td>
</tr>
</tbody>
</table>
</div>
<ScoringPointsTable />
</div>
<p>
@ -175,70 +126,7 @@ export default function Rules() {
based on their finish:
</p>
<div className="bg-muted rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Qualifying Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st</td>
<td className="p-3 text-right font-semibold text-foreground">
20 QP
</td>
</tr>
<tr>
<td className="p-3">2nd</td>
<td className="p-3 text-right font-semibold text-foreground">
14 QP
</td>
</tr>
<tr>
<td className="p-3">3rd</td>
<td className="p-3 text-right font-semibold text-foreground">
10 QP
</td>
</tr>
<tr>
<td className="p-3">4th</td>
<td className="p-3 text-right font-semibold text-foreground">
8 QP
</td>
</tr>
<tr>
<td className="p-3">5th6th</td>
<td className="p-3 text-right font-semibold text-foreground">
5 QP
</td>
</tr>
<tr>
<td className="p-3">7th8th</td>
<td className="p-3 text-right font-semibold text-foreground">
3 QP
</td>
</tr>
<tr>
<td className="p-3">9th12th</td>
<td className="p-3 text-right font-semibold text-foreground">
2 QP
</td>
</tr>
<tr>
<td className="p-3">13th16th</td>
<td className="p-3 text-right font-semibold text-foreground">
1 QP
</td>
</tr>
</tbody>
</table>
</div>
<QualifyingPointsTable />
<p>
Qualifying points are not added to a participant's total league