diff --git a/app/app.css b/app/app.css index 9f9efc8..0218139 100644 --- a/app/app.css +++ b/app/app.css @@ -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); } diff --git a/app/components/TeamAvatar.stories.tsx b/app/components/TeamAvatar.stories.tsx new file mode 100644 index 0000000..eb6cda1 --- /dev/null +++ b/app/components/TeamAvatar.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TeamAvatar } from "./TeamAvatar"; + +const meta: Meta = { + title: "UI/TeamAvatar", + component: TeamAvatar, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => ( +
+
+ + sm +
+
+ + md +
+
+ + lg +
+
+ ), +}; diff --git a/app/components/TeamAvatar.tsx b/app/components/TeamAvatar.tsx index 9cf53c9..c7f9615 100644 --- a/app/components/TeamAvatar.tsx +++ b/app/components/TeamAvatar.tsx @@ -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+/) diff --git a/app/components/draft/CommissionerAutodraftDialog.stories.tsx b/app/components/draft/CommissionerAutodraftDialog.stories.tsx new file mode 100644 index 0000000..71bb208 --- /dev/null +++ b/app/components/draft/CommissionerAutodraftDialog.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CommissionerAutodraftDialog } from "./CommissionerAutodraftDialog"; + +const meta: Meta = { + title: "Draft/CommissionerAutodraftDialog", + component: CommissionerAutodraftDialog, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/app/components/draft/CommissionerAutodraftDialog.tsx b/app/components/draft/CommissionerAutodraftDialog.tsx new file mode 100644 index 0000000..175521c --- /dev/null +++ b/app/components/draft/CommissionerAutodraftDialog.tsx @@ -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 ( + { + onOpenChange(isOpen); + }} + > + + + Set Autodraft + + {teamId + ? `Change autodraft for ${teamName ?? "this team"}` + : "Set a team's autodraft settings"} + + + {teamId && ( + + )} + + + ); +} diff --git a/app/components/draft/CommissionerDraftControls.stories.tsx b/app/components/draft/CommissionerDraftControls.stories.tsx new file mode 100644 index 0000000..4eb2ca2 --- /dev/null +++ b/app/components/draft/CommissionerDraftControls.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CommissionerDraftControls } from "./CommissionerDraftControls"; + +const meta: Meta = { + title: "Draft/CommissionerDraftControls", + component: CommissionerDraftControls, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/app/components/draft/CommissionerDraftControls.tsx b/app/components/draft/CommissionerDraftControls.tsx new file mode 100644 index 0000000..8f1f494 --- /dev/null +++ b/app/components/draft/CommissionerDraftControls.tsx @@ -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 ( + + ); + } + + if (seasonStatus === "draft" && !isDraftComplete) { + return isPaused ? ( + + ) : ( + + ); + } + + return null; +} diff --git a/app/components/draft/DraftPickCell.stories.tsx b/app/components/draft/DraftPickCell.stories.tsx index cd02721..0848577 100644 --- a/app/components/draft/DraftPickCell.stories.tsx +++ b/app/components/draft/DraftPickCell.stories.tsx @@ -238,3 +238,26 @@ export const CoronaStatesRow: Story = { ), }; + +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, + }, +}; diff --git a/app/components/draft/DraftPickCell.tsx b/app/components/draft/DraftPickCell.tsx index 1f46b07..9b8193c 100644 --- a/app/components/draft/DraftPickCell.tsx +++ b/app/components/draft/DraftPickCell.tsx @@ -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 = { - 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 ? ( -
+
) : coronaState ? (
); }); +DraftPickCell.displayName = "DraftPickCell"; diff --git a/app/components/draft/RollbackConfirmDialog.stories.tsx b/app/components/draft/RollbackConfirmDialog.stories.tsx new file mode 100644 index 0000000..d2c95ea --- /dev/null +++ b/app/components/draft/RollbackConfirmDialog.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RollbackConfirmDialog } from "./RollbackConfirmDialog"; + +const meta: Meta = { + title: "Draft/RollbackConfirmDialog", + component: RollbackConfirmDialog, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/app/components/draft/RollbackConfirmDialog.tsx b/app/components/draft/RollbackConfirmDialog.tsx new file mode 100644 index 0000000..92fbcab --- /dev/null +++ b/app/components/draft/RollbackConfirmDialog.tsx @@ -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 ( + + + + Roll Back Draft + + {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.`} + + + + + + + + + ); +} diff --git a/app/components/draft/TimeBankAdjustmentDialog.stories.tsx b/app/components/draft/TimeBankAdjustmentDialog.stories.tsx new file mode 100644 index 0000000..fb3d024 --- /dev/null +++ b/app/components/draft/TimeBankAdjustmentDialog.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TimeBankAdjustmentDialog } from "./TimeBankAdjustmentDialog"; + +const meta: Meta = { + title: "Draft/TimeBankAdjustmentDialog", + component: TimeBankAdjustmentDialog, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/app/components/draft/TimeBankAdjustmentDialog.tsx b/app/components/draft/TimeBankAdjustmentDialog.tsx new file mode 100644 index 0000000..6462f58 --- /dev/null +++ b/app/components/draft/TimeBankAdjustmentDialog.tsx @@ -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 ( + + + + Adjust Time Bank + + {teamName ? `Adjusting time for ${teamName}` : "Adjust a team's time bank"} + + + +
+
+ + +
+ +
+ 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" + /> + +
+
+ + + + + +
+
+ ); +} diff --git a/app/components/league/LeagueAvatar.stories.tsx b/app/components/league/LeagueAvatar.stories.tsx new file mode 100644 index 0000000..0ea11fd --- /dev/null +++ b/app/components/league/LeagueAvatar.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LeagueAvatar } from "./LeagueAvatar"; + +const meta: Meta = { + title: "League/LeagueAvatar", + component: LeagueAvatar, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => ( +
+
+ + sm +
+
+ + default +
+
+ + lg +
+
+ ), +}; diff --git a/app/components/league/LeagueAvatar.tsx b/app/components/league/LeagueAvatar.tsx index 4774053..bc595f7 100644 --- a/app/components/league/LeagueAvatar.tsx +++ b/app/components/league/LeagueAvatar.tsx @@ -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+/) diff --git a/app/components/league/TeamsPanel.tsx b/app/components/league/TeamsPanel.tsx index 4ca927f..63f98a8 100644 --- a/app/components/league/TeamsPanel.tsx +++ b/app/components/league/TeamsPanel.tsx @@ -53,7 +53,7 @@ export function TeamsPanel({ teams, currentUserId, ownerMap, draftSlots }: Teams
diff --git a/app/components/marketing/BracketDecor.stories.tsx b/app/components/marketing/BracketDecor.stories.tsx new file mode 100644 index 0000000..5e4a242 --- /dev/null +++ b/app/components/marketing/BracketDecor.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BracketDecor } from "./BracketDecor"; + +const meta: Meta = { + title: "Marketing/BracketDecor", + component: BracketDecor, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Flipped: Story = { + args: { + flip: true, + }, +}; + +export const MobileTall: Story = { + args: { + mobileTall: true, + }, +}; diff --git a/app/components/marketing/BracketDecor.tsx b/app/components/marketing/BracketDecor.tsx new file mode 100644 index 0000000..51aed24 --- /dev/null +++ b/app/components/marketing/BracketDecor.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {r3Ys.map((midY) => ( + + ))} + + + + {r3Ys.map((midY) => ( + + ))} + + + + {r1Ys.map((y) => ( + + + + + ))} + {r2Ys.map((midY, i) => ( + + + + + + ))} + {r3Ys.map((midY, i) => ( + + + + + + ))} + + + + + + ); +} diff --git a/app/components/marketing/LandingPage.tsx b/app/components/marketing/LandingPage.tsx index b044bdd..d40f91d 100644 --- a/app/components/marketing/LandingPage.tsx +++ b/app/components/marketing/LandingPage.tsx @@ -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(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { @@ -66,7 +64,7 @@ function SlotMachineHeadline() { return ( Every Sport. @@ -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 ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {r3Ys.map((midY) => ( - - ))} - - - - {r3Ys.map((midY) => ( - - ))} - - - - {r1Ys.map((y) => ( - - - - - ))} - {r2Ys.map((midY, i) => ( - - - - - - ))} - {r3Ys.map((midY, i) => ( - - - - - - ))} - - - - - - ); -} - // ─── 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() { diff --git a/app/components/marketing/ScoringTables.stories.tsx b/app/components/marketing/ScoringTables.stories.tsx new file mode 100644 index 0000000..9b2dc48 --- /dev/null +++ b/app/components/marketing/ScoringTables.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ScoringPointsTable, QualifyingPointsTable } from "./ScoringTables"; + +const meta: Meta = { + title: "Marketing/ScoringTables", + component: ScoringPointsTable, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +export const ScoringPoints: Story = { + render: (args) => , + args: {}, +}; + +export const QualifyingPoints: Story = { + render: (args) => , + args: {}, +}; + +export const BothTables: Story = { + name: "Both Tables — Side by Side", + render: () => ( +
+ + +
+ ), +}; diff --git a/app/components/marketing/ScoringTables.tsx b/app/components/marketing/ScoringTables.tsx new file mode 100644 index 0000000..bbc72b7 --- /dev/null +++ b/app/components/marketing/ScoringTables.tsx @@ -0,0 +1,51 @@ +interface TableProps { + className?: string; +} + +export function ScoringPointsTable({ className }: TableProps) { + return ( +
+ + + + + + + + + + + + + + + +
FinishPoints
1st Place100
2nd Place70
3rd Place50
4th Place40
5th–6th Place25
7th–8th Place15
+
+ ); +} + +export function QualifyingPointsTable({ className }: TableProps) { + return ( +
+ + + + + + + + + + + + + + + + + +
Major FinishQualifying Points
1st20 QP
2nd14 QP
3rd10 QP
4th8 QP
5th–6th5 QP
7th–8th3 QP
9th–12th2 QP
13th–16th1 QP
+
+ ); +} diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx index 2e24576..836ea2f 100644 --- a/app/components/navbar.tsx +++ b/app/components/navbar.tsx @@ -28,8 +28,8 @@ export function Navbar({ isAdmin }: NavbarProps) { Brackt
diff --git a/app/components/scoring/BracketTreePaginated.tsx b/app/components/scoring/BracketTreePaginated.tsx new file mode 100644 index 0000000..87acd97 --- /dev/null +++ b/app/components/scoring/BracketTreePaginated.tsx @@ -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; + ownershipMap: Map; + userParticipantIds: Set; + /** 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 ( +
+
+ + + {label} + + +
+ +
+
+
+ +
+ {anim?.phase === "sliding" && ( +
+ +
+ )} +
+
+ + {thirdPlaceMatch && ( +
+
+ 3rd Place +
+ +
+ )} +
+ ); +} diff --git a/app/components/scoring/BracketTreeView.tsx b/app/components/scoring/BracketTreeView.tsx index 2e75de3..98e4f35 100644 --- a/app/components/scoring/BracketTreeView.tsx +++ b/app/components/scoring/BracketTreeView.tsx @@ -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; - ownershipMap: Map; - userParticipantIds: Set; - /** 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(null); - const stripRef = useRef(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 ( -
-
- - - {label} - - -
- -
-
-
- -
- {anim?.phase === "sliding" && ( -
- -
- )} -
-
- - {thirdPlaceMatch && ( -
-
- 3rd Place -
- -
- )} -
- ); -} diff --git a/app/components/scoring/NbaBracketLayout.tsx b/app/components/scoring/NbaBracketLayout.tsx index a10b841..86e7c70 100644 --- a/app/components/scoring/NbaBracketLayout.tsx +++ b/app/components/scoring/NbaBracketLayout.tsx @@ -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 }>; diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 8dc1767..ec57385 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -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({
{bracketWinner && ( -
-
- -
-

- {bracketWinner.name} - {winnerIsOwned && } -

- {winnerOwnership?.ownerName && ( -

{winnerOwnership.ownerName}

- )} -
-
-
-
-

Rank

- 1 -
- {pointsMap.size > 0 && ( - <> -
-
-

Points

- {winnerPts ?? 0} -
- - )} -
-
+ 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 ( -
-
- -
-

- {entry.participant.name} - {isOwned && } -

- {entry.ownership?.ownerName && ( -

{entry.ownership.ownerName}

- )} -
-
-
-
-

Rank

- {entry.rankLabel} -
- {pointsMap.size > 0 && ( - <> -
-
-

Points

- {pts ?? 0} -
- - )} -
-
+ 0} + rowClass={rankingRowCls(numRank)} + /> ); })} @@ -516,31 +471,17 @@ export function PlayoffBracket({ const ownership = ownershipMap.get(p.id); const pts = pointsMap.get(p.id); return ( -
-
- -
-

- {p.name} - {isOwned && } -

- {ownership?.ownerName && ( -

{ownership.ownerName}

- )} -
-
- {pointsMap.size > 0 && ( -
-
-

Points

- {pts ?? 0} -
-
- )} -
+ 0} + rowClass={rankingRowCls(undefined)} + /> ); })}
diff --git a/app/components/scoring/RankingsRow.stories.tsx b/app/components/scoring/RankingsRow.stories.tsx new file mode 100644 index 0000000..98695d0 --- /dev/null +++ b/app/components/scoring/RankingsRow.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RankingsRow } from "./RankingsRow"; + +const meta: Meta = { + title: "Scoring/RankingsRow", + component: RankingsRow, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/app/components/scoring/RankingsRow.tsx b/app/components/scoring/RankingsRow.tsx new file mode 100644 index 0000000..8762f54 --- /dev/null +++ b/app/components/scoring/RankingsRow.tsx @@ -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 ( +
+
+ +
+

+ {participantName} + {isOwned && } +

+ {ownerName && ( +

{ownerName}

+ )} +
+
+ {(rankLabel !== undefined || showPoints) && ( +
+ {rankLabel !== undefined && ( +
+

Rank

+ {rankLabel} +
+ )} + {showPoints && rankLabel !== undefined && ( +
+ )} + {showPoints && ( +
+

Points

+ {points ?? 0} +
+ )} +
+ )} +
+ ); +} diff --git a/app/components/scoring/TabbedBracketLayout.tsx b/app/components/scoring/TabbedBracketLayout.tsx index 45440f0..95b377b 100644 --- a/app/components/scoring/TabbedBracketLayout.tsx +++ b/app/components/scoring/TabbedBracketLayout.tsx @@ -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[]; diff --git a/app/components/standings/PointProgressionChart.tsx b/app/components/standings/PointProgressionChart.tsx index e3a2d53..14307a1 100644 --- a/app/components/standings/PointProgressionChart.tsx +++ b/app/components/standings/PointProgressionChart.tsx @@ -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 === ' ') { diff --git a/app/components/ui/BracktGradients.tsx b/app/components/ui/BracktGradients.tsx index d3e10e1..8037a32 100644 --- a/app/components/ui/BracktGradients.tsx +++ b/app/components/ui/BracktGradients.tsx @@ -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 + 0→24 matches the Lucide icon viewBox so horizontal strokes (zero bounding-box height) don't produce a degenerate gradient. */} - - + + diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx index fc70709..437906f 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -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: diff --git a/app/components/ui/card.tsx b/app/components/ui/card.tsx index eda5231..ad2e874 100644 --- a/app/components/ui/card.tsx +++ b/app/components/ui/card.tsx @@ -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: { diff --git a/app/components/ui/team-owner-badge.stories.tsx b/app/components/ui/team-owner-badge.stories.tsx new file mode 100644 index 0000000..9e8cc35 --- /dev/null +++ b/app/components/ui/team-owner-badge.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TeamOwnerBadge } from "./team-owner-badge"; + +const meta: Meta = { + title: "UI/TeamOwnerBadge", + component: TeamOwnerBadge, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => ( +
+ + + + +
+ ), +}; diff --git a/app/components/ui/team-owner-badge.tsx b/app/components/ui/team-owner-badge.tsx index 66fb950..f756342 100644 --- a/app/components/ui/team-owner-badge.tsx +++ b/app/components/ui/team-owner-badge.tsx @@ -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 = (
>; +type Season = NonNullable>>; +type Timers = Awaited>; +type AutodraftSettings = Awaited>; + +interface UseDraftAuthRecoveryParams { + reconnectCount: number; + revalidate: () => void; + revalidatorState: "idle" | "loading" | "submitting"; + clerkUserId: string | null | undefined; + currentUserId: string | null | undefined; + getToken: (opts?: { skipCache?: boolean }) => Promise; + 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) => Record) => void; + setAutodraftStatus: (fn: () => Record) => 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 | 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 | 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([]); + 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 = {}; + 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 => { + 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, + }; +} diff --git a/app/hooks/useDraftRoomState.ts b/app/hooks/useDraftRoomState.ts new file mode 100644 index 0000000..1511bab --- /dev/null +++ b/app/hooks/useDraftRoomState.ts @@ -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>; +type Season = NonNullable>>; +type DraftSlots = Awaited>; +type AutodraftSettings = Awaited>; +type Timers = Awaited>; + +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([]); + const [queue, setQueue] = useState(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>(() => { + const status: Record = {}; + autodraftSettings.forEach((setting) => { + status[setting.teamId] = { + isEnabled: setting.isEnabled, + mode: setting.mode, + queueOnly: setting.queueOnly, + }; + }); + return status; + }); + + const [connectedTeams, setConnectedTeams] = useState>(new Set()); + + const [teamTimers, setTeamTimers] = useState>(() => { + const timersMap: Record = {}; + 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(null); + const [isRollingBack, setIsRollingBack] = useState(false); + + const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false); + const [timeBankTeamId, setTimeBankTeamId] = useState(null); + const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false); + const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState(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, + }; +} diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts new file mode 100644 index 0000000..66b1249 --- /dev/null +++ b/app/hooks/useDraftSocketEvents.ts @@ -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>; +type DraftSlots = Awaited>; +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; + pendingPicksDuringRevalidationRef: MutableRefObject; + pendingQueueMutationsRef: MutableRefObject; + 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; + // State setters + setPicks: (fn: (prev: DraftPicks) => DraftPicks) => void; + setCurrentPick: (pick: number) => void; + setIsPaused: (paused: boolean) => void; + setIsDraftComplete: (complete: boolean) => void; + setAutodraftStatus: (fn: (prev: Record) => Record) => void; + setUserAutodraft: (value: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => void; + setConnectedTeams: (fn: (prev: Set) => Set) => void; + setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void; + setTeamTimers: (fn: (prev: Record) => Record) => 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]); +} diff --git a/app/hooks/useRoundTransition.ts b/app/hooks/useRoundTransition.ts new file mode 100644 index 0000000..2d1c3bd --- /dev/null +++ b/app/hooks/useRoundTransition.ts @@ -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(null); + const stripRef = useRef(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 }; +} diff --git a/app/lib/auth.ts b/app/lib/auth.ts new file mode 100644 index 0000000..569d273 --- /dev/null +++ b/app/lib/auth.ts @@ -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; + +/** + * 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 { + 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 }); + } +} diff --git a/app/lib/avatar-colors.ts b/app/lib/avatar-colors.ts new file mode 100644 index 0000000..fb02ae3 --- /dev/null +++ b/app/lib/avatar-colors.ts @@ -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]; +} diff --git a/app/lib/corona-states.ts b/app/lib/corona-states.ts new file mode 100644 index 0000000..db35e03 --- /dev/null +++ b/app/lib/corona-states.ts @@ -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, + bracketTemplateBySportsSeason: Map, + scoringRules: ScoringRules, + maxPoints: number, +): Record { + const coronaStates: Record = {}; + + 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; +} diff --git a/app/lib/landing-data.ts b/app/lib/landing-data.ts new file mode 100644 index 0000000..eb51d03 --- /dev/null +++ b/app/lib/landing-data.ts @@ -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.", + }, +]; diff --git a/app/models/__tests__/draft-pick.test.ts b/app/models/__tests__/draft-pick.test.ts new file mode 100644 index 0000000..d6475a3 --- /dev/null +++ b/app/models/__tests__/draft-pick.test.ts @@ -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(); + }); +}); diff --git a/app/models/__tests__/team-score-events.test.ts b/app/models/__tests__/team-score-events.test.ts new file mode 100644 index 0000000..9caaf99 --- /dev/null +++ b/app/models/__tests__/team-score-events.test.ts @@ -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([]); + }); +}); diff --git a/app/models/autodraft-settings.ts b/app/models/autodraft-settings.ts new file mode 100644 index 0000000..b83eed4 --- /dev/null +++ b/app/models/autodraft-settings.ts @@ -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 { + const db = database(); + return await db.query.autodraftSettings.findMany({ + where: eq(schema.autodraftSettings.seasonId, seasonId), + }); +} diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts index 5fb3b7f..1c7b17b 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -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)); +} diff --git a/app/models/participant.ts b/app/models/participant.ts index 279b9d5..686db3e 100644 --- a/app/models/participant.ts +++ b/app/models/participant.ts @@ -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)); +} diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 4e36e43..1d008af 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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 { 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 { 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); diff --git a/app/models/season.ts b/app/models/season.ts index 71ea0fc..78e592f 100644 --- a/app/models/season.ts +++ b/app/models/season.ts @@ -130,6 +130,17 @@ export async function deleteSeason(id: string): Promise { 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 { diff --git a/app/models/standings.ts b/app/models/standings.ts index ca1e633..97acc5f 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -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( diff --git a/app/routes/admin.data-sync.tsx b/app/routes/admin.data-sync.tsx index 518f41a..fff5737 100644 --- a/app/routes/admin.data-sync.tsx +++ b/app/routes/admin.data-sync.tsx @@ -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, diff --git a/app/routes/api/autodraft.update.ts b/app/routes/api/autodraft.update.ts index ce28978..651cd8c 100644 --- a/app/routes/api/autodraft.update.ts +++ b/app/routes/api/autodraft.update.ts @@ -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), }); diff --git a/app/routes/how-to-play.tsx b/app/routes/how-to-play.tsx index 5eb224a..426e3b4 100644 --- a/app/routes/how-to-play.tsx +++ b/app/routes/how-to-play.tsx @@ -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:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Finish - - Points -
1st Place - 100 -
2nd Place - 70 -
3rd Place - 50 -
4th Place - 40 -
5th–6th Place - 25 -
7th–8th Place - 15 -
-
+

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() { {" "} at each one:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Major Finish - - Qualifying Points -
1st - 20 QP -
2nd - 14 QP -
3rd - 10 QP -
4th - 8 QP -
5th–6th - 5 QP -
7th–8th - 3 QP -
9th–12th - 2 QP -
13th–16th - 1 QP -
-
+

QP don't go straight to your score — they determine the final ranking within that sport. Once all the majors are done, total QP diff --git a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx index f1c1a1a..094a1b0 100644 --- a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx @@ -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 = {}; + let coronaStates: Record = {}; 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(); 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 { diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index db31728..af39dac 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -1,16 +1,26 @@ import { useLoaderData, Link, useRevalidator } from "react-router"; import { useAuth } from "@clerk/react-router"; -import { eq, and, asc, desc, inArray } from "drizzle-orm"; -import { database } from "~/database/context"; -import * as schema from "~/database/schema"; import { useDraftSocket } from "~/hooks/useDraftSocket"; import { logger } from "~/lib/logger"; -import { useCallback, useEffect, useState, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog"; import { getAuth } from "@clerk/react-router/server"; +import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState"; +import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery"; +import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents"; +import { RollbackConfirmDialog } from "~/components/draft/RollbackConfirmDialog"; +import { TimeBankAdjustmentDialog } from "~/components/draft/TimeBankAdjustmentDialog"; +import { CommissionerAutodraftDialog } from "~/components/draft/CommissionerAutodraftDialog"; +import { CommissionerDraftControls } from "~/components/draft/CommissionerDraftControls"; import { getTeamQueue } from "~/models/draft-queue"; +import { findSeasonWithTeamsAndLeague } from "~/models/season"; +import { findDraftSlotsBySeasonId } from "~/models/draft-slot"; +import { getDraftPicksForSeason } from "~/models/draft-pick"; +import { getDraftParticipants } from "~/models/participant"; +import { getSeasonTimers } from "~/models/draft-timer"; +import { getSeasonAutodraftSettings } from "~/models/autodraft-settings"; +import { hasCommissionerRecord } from "~/models/commissioner"; import logomarkUrl from "../../../public/logomark.svg?url"; import { buildOwnerMap } from "~/lib/owner-map"; import { DraftSidebar } from "~/components/DraftSidebar"; @@ -27,9 +37,8 @@ import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelect import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { getTeamForPick } from "~/lib/draft-order"; import { useDraftNotifications } from "~/hooks/useDraftNotifications"; -import { useMediaQuery } from "~/hooks/useMediaQuery"; import { NotificationSettings } from "~/components/NotificationSettings"; -import { AutodraftSettings, getAutodraftLabel, AutodraftBadgeWithPopover } from "~/components/AutodraftSettings"; +import { AutodraftBadgeWithPopover } from "~/components/AutodraftSettings"; import { toast } from "sonner"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react"; @@ -39,14 +48,6 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Draft — ${data?.season?.league?.name ?? "League"} - Brackt` }]; } -type QueueItem = typeof schema.draftQueue.$inferSelect; - -type AutodraftStatusEntry = { - isEnabled: boolean; - mode: "next_pick" | "while_on"; - queueOnly: boolean; -}; - const MOBILE_TABS_BASE = [ { id: "available" as const, label: "Participants", Icon: Users }, { id: "board" as const, label: "Board", Icon: LayoutGrid }, @@ -65,36 +66,20 @@ export async function loader(args: Route.LoaderArgs) { throw new Response("Season ID is required", { status: 400 }); } - const db = database(); - - // Get season details - const season = await db.query.seasons.findFirst({ - where: eq(schema.seasons.id, seasonId), - with: { - league: true, - teams: true, - }, - }); + const season = await findSeasonWithTeamsAndLeague(seasonId); if (!season) { throw new Response("Season not found", { status: 404 }); } - // Resolve user's team and commissioner status (null userId means neither) const userTeam = userId ? season.teams.find((team) => team.ownerId === userId) : undefined; const isCommissioner = userId - ? await db.query.commissioners.findFirst({ - where: and( - eq(schema.commissioners.leagueId, leagueId), - eq(schema.commissioners.userId, userId) - ), - }) - : null; + ? await hasCommissionerRecord(leagueId, userId) + : false; - // Access control: if draft board is not public, require authentication and membership if (!season.league.isPublicDraftBoard) { if (!userId) { throw new Response("You must be logged in to view the draft room", { @@ -108,85 +93,15 @@ export async function loader(args: Route.LoaderArgs) { } } - // Get draft slots (draft order) - using select instead of query builder - const draftSlots = await db - .select({ - id: schema.draftSlots.id, - draftOrder: schema.draftSlots.draftOrder, - team: schema.teams, - }) - .from(schema.draftSlots) - .innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id)) - .where(eq(schema.draftSlots.seasonId, seasonId)) - .orderBy(asc(schema.draftSlots.draftOrder)); + const [draftSlots, draftPicks, availableParticipants, timers, autodraftSettings] = + await Promise.all([ + findDraftSlotsBySeasonId(seasonId), + getDraftPicksForSeason(seasonId), + getDraftParticipants(seasonId), + getSeasonTimers(seasonId), + getSeasonAutodraftSettings(seasonId), + ]); - // Get all draft picks - using select instead of query builder - const draftPicks = 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)); - - // Get sports seasons for this season - const seasonSports = await db.query.seasonSports.findMany({ - where: eq(schema.seasonSports.seasonId, seasonId), - }); - - const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId); - - // Get ALL participants (both drafted and undrafted) - sorted by EV desc, then name - // Filtering will be done on the client side based on the "Show Drafted" toggle - const availableParticipants = sportsSeasonIds.length > 0 - ? 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) - ) - : []; - - // Load user's team queue if they have a team const userQueue = userTeam ? await getTeamQueue(userTeam.id).catch((err) => { logger.error("[DraftLoader] Failed to load queue, defaulting to empty:", err); @@ -194,17 +109,6 @@ export async function loader(args: Route.LoaderArgs) { }) : []; - // Load draft timers for all teams - const timers = await db.query.draftTimers.findMany({ - where: eq(schema.draftTimers.seasonId, seasonId), - }); - - // Load autodraft settings for all teams - const autodraftSettings = await db.query.autodraftSettings.findMany({ - where: eq(schema.autodraftSettings.seasonId, seasonId), - }); - - // Derive user's autodraft settings from the already-loaded list const userAutodraftSettings = userTeam ? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null) : null; @@ -265,352 +169,83 @@ export default function DraftRoom() { notificationsModeRef.current = notificationsMode; }, [notificationsMode]); - // Track current autodraft state for detecting server-initiated auto-disable - 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. - // Schedule a delayed retry so that if the first attempt fails (common on mobile - // where the network may not be fully restored yet), we get another chance. - const revalidationRetryRef = useRef | null>(null); - useEffect(() => { - if (reconnectCount > 0) { - revalidate(); - // Clear any pending retry from a previous reconnect - 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: if the loader ran without a userId - // (expired short-lived JWT) but the Clerk client SDK has a valid session, - // revalidate so the loader re-runs with a fresh token and returns correct - // userTeam/userQueue data. This can happen repeatedly on mobile when the - // user backgrounds and foregrounds the app, so we don't limit it to a - // single attempt. Instead we debounce to avoid rapid-fire revalidations. - // - // Cap at 3 attempts to avoid an infinite loop if the server consistently - // fails to resolve a userId (e.g. clock skew, misconfigured Clerk keys). - // After 3 failures we fall through to the authDegraded overlay. - const authRecoveryTimerRef = useRef | undefined>(undefined); - const authRecoveryAttemptsRef = useRef(0); - const MAX_AUTH_RECOVERY_ATTEMPTS = 3; - useEffect(() => { - if (clerkUserId && !currentUserId) { - if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) { - setAuthDegraded(true); - return; - } - // Small debounce so we don't fire multiple times in quick succession - // (e.g. visibility change + socket reconnect both updating deps). - clearTimeout(authRecoveryTimerRef.current); - authRecoveryTimerRef.current = setTimeout(() => { - authRecoveryAttemptsRef.current += 1; - revalidate(); - }, 100); - } else if (currentUserId) { - // Auth recovered — reset the counter for the next background cycle. - authRecoveryAttemptsRef.current = 0; - } - return () => clearTimeout(authRecoveryTimerRef.current); - }, [clerkUserId, currentUserId, revalidate]); - - // Auth session protection: proactively refresh the Clerk JWT to prevent - // silent logout during long-running draft sessions. - const [authDegraded, setAuthDegraded] = useState(false); - - // Fetch wrapper that detects 401 responses and shows the auth recovery - // overlay instead of letting each handler deal with it individually. - // Returns null on 401 so callers can bail with `if (!response) return;`. - const authFetch = useCallback(async (url: string, init?: RequestInit): Promise => { - const response = await fetch(url, init); - if (response.status === 401) { - setAuthDegraded(true); - return null; - } - return response; - }, []); - - // Refresh the Clerk JWT when the tab becomes visible again. Browsers - // throttle/suspend intervals in background tabs, so the SDK's automatic - // 60-second refresh may not have run. Getting a fresh token here prevents - // the next user action from hitting a 401. After a successful refresh we - // also revalidate the loader so it re-runs with the fresh token — without - // this, userTeam/userQueue stay stale from the previous (possibly - // unauthenticated) loader run. - useEffect(() => { - // Nothing to do once auth is fully degraded — avoid re-registering a - // listener that would immediately exit on every future visibility change. - if (authDegraded) return; - - let aborted = false; - let inFlight = false; - - const handleVisibilityChange = async () => { - // Ignore hide events, and skip if another invocation is already running - // (rapid tab switching) or this effect has been cleaned up. - if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return; - inFlight = true; - try { - const token = await getToken({ skipCache: true }); - if (aborted) return; - if (token !== null) { - // Token refreshed successfully. Only revalidate if the loader is - // missing auth (currentUserId is null) — if the loader already has - // a valid user there's no stale state to fix and the extra round - // trip just adds unnecessary server load. - if (!currentUserId) { - revalidate(); - } - } else { - // Retry after a longer delay — mobile networks may take a few - // seconds to fully wake up after background suspension. - 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. - // - // The problem: `picks` and `currentPick` are local state initialized once - // from useLoaderData(). When revalidate() runs after a reconnect, React - // Router re-fetches `draftPicks`/`season` — but those updates never flow - // back into the local useState copies automatically. - // - // Race condition: a `pick-made` socket event that arrives *during* the - // revalidation window would normally be applied to the stale prev state and - // then overwritten when we sync from the DB snapshot. We prevent that by - // buffering live picks while revalidation is in flight and merging them - // (deduplicated by ID) after the DB snapshot lands. - const revalidatorStateRef = useRef(revalidatorState); - const isRevalidatingRef = useRef(false); - const pendingPicksDuringRevalidationRef = useRef<(typeof draftPicks)[number][]>([]); - // Track the draftPicks reference when revalidation starts. If it hasn't - // changed by the time revalidation completes, the loader didn't return new - // data (e.g. network failure) and we should NOT overwrite state that may - // have been freshly set by draft-state-sync. - const draftPicksAtRevalidationStartRef = useRef(draftPicks); - // Same pattern for userQueue: only sync from loader if the reference changed, - // meaning the fetch actually returned fresh data. Prevents overwriting - // socket-applied queue changes (queue-updated, participant-removed-from-queues) - // with stale loader data when a revalidation fetch fails. - const userQueueAtRevalidationStartRef = useRef(userQueue); - // Tracks how many queue mutations are in-flight from this window. While > 0, - // incoming queue-updated socket events are ignored so that the server echo - // doesn't clobber a more-recent optimistic update (e.g. rapid drag-reorders). - // The initiating window uses the HTTP response as the authoritative state; - // other windows use the socket event. - const pendingQueueMutationsRef = useRef(0); - - useEffect(() => { - const prev = revalidatorStateRef.current; - revalidatorStateRef.current = revalidatorState; - - if (prev !== "loading" && revalidatorState === "loading") { - // Revalidation just started — buffer any live picks to avoid loss - isRevalidatingRef.current = true; - pendingPicksDuringRevalidationRef.current = []; - draftPicksAtRevalidationStartRef.current = draftPicks; - userQueueAtRevalidationStartRef.current = userQueue; - } else if (prev === "loading" && revalidatorState === "idle") { - // Revalidation just completed — merge DB snapshot with buffered picks - isRevalidatingRef.current = false; - - // If draftPicks is the same reference as when revalidation started, the - // loader didn't actually update (likely a failed fetch). Skip the sync - // to avoid overwriting fresh draft-state-sync data with stale state. - 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" - ); - // Sync the queue if the loader returned fresh data for it. Using reference - // equality (same pattern as draftPicks above): drizzle always returns a new - // array on a successful fetch, so a changed reference means the fetch - // succeeded and we can trust the value. A same reference means the fetch - // failed and we must NOT overwrite socket-applied changes (queue-updated, - // participant-removed-from-queues) with stale data. - // - // Guard: skip if the loader ran unauthenticated (currentUserId null) to avoid - // overwriting with an empty userQueue returned for an expired JWT session. - if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) { - setQueue(userQueue); - } - // Sync timers — the server-side timer state may have changed while - // we were disconnected (time bank adjustments, new timers, etc.) - if (timers.length > 0) { - setTeamTimers((currentTimers) => { - const updated = { ...currentTimers }; - timers.forEach((timer) => { - updated[timer.teamId] = timer.timeRemaining; - }); - return updated; - }); - } - // Sync autodraft settings — teams may have toggled autodraft while - // we were away. - setAutodraftStatus(() => { - const status: Record = {}; - autodraftSettings.forEach((setting) => { - status[setting.teamId] = { - isEnabled: setting.isEnabled, - mode: setting.mode, - queueOnly: setting.queueOnly, - }; - }); - return status; - }); - } - }, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId]); - - 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([]); - const [queue, setQueue] = useState(userQueue); - const [isPaused, setIsPaused] = useState(season.draftPaused || false); - const [isDraftComplete, setIsDraftComplete] = useState( - season.status === "active" || season.status === "completed" - ); - - // Sidebar and tabs state - 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)"); - - // Track autodraft status for all teams - const [autodraftStatus, setAutodraftStatus] = useState>(() => { - const status: Record = {}; - autodraftSettings.forEach((setting) => { - status[setting.teamId] = { - isEnabled: setting.isEnabled, - mode: setting.mode, - queueOnly: setting.queueOnly, - }; - }); - return status; + const { + 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, + } = useDraftRoomState({ + draftPicks, + season, + userTeam, + isCommissioner, + autodraftSettings, + timers, + draftSlots, + userQueue, }); - // Track connected teams - const [connectedTeams, setConnectedTeams] = useState>(new Set()); - - const [teamTimers, setTeamTimers] = useState>(() => { - // Initialize with timers from loader data, or use initial time if draft hasn't started - const timersMap: Record = {}; - const initialTime = season.draftInitialTime || 120; - - if (timers.length > 0) { - // Draft has started, use actual timers - timers.forEach((timer) => { - timersMap[timer.teamId] = timer.timeRemaining; - }); - } else { - // Draft hasn't started, show initial time for all teams - draftSlots.forEach((slot) => { - timersMap[slot.team.id] = initialTime; - }); - } - - return timersMap; + const { + authDegraded, + authFetch, + userAutodraft, + setUserAutodraft, + userAutodraftRef, + isRevalidatingRef, + pendingPicksDuringRevalidationRef, + pendingQueueMutationsRef, + } = useDraftAuthRecovery({ + reconnectCount, + revalidate, + revalidatorState, + clerkUserId, + currentUserId, + getToken, + userAutodraftSettings, + draftPicks, + season, + userQueue, + timers, + autodraftSettings, + setPicks, + setCurrentPick, + setIsPaused, + setIsDraftComplete, + setQueue, + setTeamTimers, + setAutodraftStatus, }); - const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false); - const [selectedPickSlot, setSelectedPickSlot] = useState<{ - pickNumber: number; - teamId: string; - } | null>(null); - // Replace pick dialog state - const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false); - const [replacePickSlot, setReplacePickSlot] = useState<{ - pickNumber: number; - teamId: string; - oldParticipantId: string; - } | null>(null); - // Rollback confirm dialog state - const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false); - const [rollbackPickNumber, setRollbackPickNumber] = useState(null); - const [isRollingBack, setIsRollingBack] = useState(false); - - // Time bank adjustment dialog state - const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false); - const [timeBankTeamId, setTimeBankTeamId] = useState(null); - const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false); - const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState(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); // Shared transforms for eligibility calculations const transformedPicks = useMemo( @@ -711,236 +346,29 @@ export default function DraftRoom() { ); }, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]); - // Listen for new picks from other users - useEffect(() => { - type PickMadePayload = { pick: (typeof draftPicks)[number] & { team?: { id?: string; name?: string }; participant?: { name?: string } }; nextPickNumber: number; isDraftComplete?: boolean }; - const handlePickMade = (data: PickMadePayload) => { - if (isRevalidatingRef.current) { - // Buffer this pick; the revalidation-complete handler will merge it - // into the DB snapshot so it isn't silently dropped. - // Don't update currentPick either — the sync effect will set it from - // fresh season.currentPickNumber once loading → idle, keeping the - // pick counter and the picks list in sync with each other. - pendingPicksDuringRevalidationRef.current.push(data.pick); - } else { - setPicks((prev) => [...prev, data.pick]); - setCurrentPick(data.nextPickNumber); - } - - // No meaningful "next turn" notification once the draft is over - if (data.isDraftComplete) return; - - const notifTitle = `${season.league.name} 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 - ) { - // Only notify about other teams' picks, not the user's own - 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 }) => { - // Bail out without creating a new object reference if nothing changed. - // Without this guard the spread `{ ...prev }` always returns a new object, - // causing a full re-render of the DraftRoom tree on every 1-second tick. - setTeamTimers((prev) => { - if (prev[data.teamId] === data.timeRemaining) return prev; - return { ...prev, [data.teamId]: data.timeRemaining }; - }); - // Also sync the current pick number from the server. This is critical - // for mobile reconnection: timer-update events arrive every second, so - // this ensures currentPick is correct within 1s of reconnecting — even - // if the HTTP revalidation hasn't completed yet. - // React's useState already bails out when the new value equals the old - // one (Object.is comparison on primitives), so no manual guard needed. - 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 }, - })); - - // Update user's local state if it's their team - 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 { - // Detect server-side auto-disable: was enabled with queueOnly, now disabled - 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))); - }; - - // Fired when another window/session changes the user's queue (add/remove/reorder/clear). - // Only received by this team's own sockets (emitted to team-${teamId} private room). - // Ignored while this window has in-flight mutations to prevent the server echo - // from clobbering a more-recent optimistic update. - const handleQueueUpdated = (data: { queue: QueueItem[] }) => { - if (pendingQueueMutationsRef.current > 0) return; - setQueue(data.queue); - }; - - const handlePickReplaced = (data: { pickNumber: number; pick: (typeof draftPicks)[number] }) => { - 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); - }; - - // Full state sync sent by the server on join-draft. Provides an immediate, - // socket-based path to bring the client up to date — critical for mobile - // reconnection where the HTTP revalidation may be delayed or fail entirely. - const handleDraftStateSync = (data: { picks: (typeof draftPicks)[number][]; currentPickNumber: number; isPaused: boolean; status: string; timers?: Array<{ teamId: string; timeRemaining: number }>; queue?: QueueItem[] }) => { - // If a revalidation is in-flight, let it complete and handle the merge. - // The revalidation path already buffers picks and deduplicates — layering - // this on top would risk conflicts. But if NOT revalidating, apply the - // server snapshot directly. - 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) { - setQueue(data.queue); - } - } - }; - - on("pick-made", handlePickMade); - on("timer-update", handleTimerUpdate); - on("draft-paused", handleDraftPaused); - on("draft-resumed", handleDraftResumed); - on("draft-completed", handleDraftCompleted); - on("autodraft-updated", handleAutodraftUpdated); - on("team-connected", handleTeamConnected); - on("team-disconnected", handleTeamDisconnected); - on("connected-teams-list", handleConnectedTeamsList); - on("participant-removed-from-queues", handleParticipantRemovedFromQueues); - on("queue-eligibility-pruned", handleQueueEligibilityPruned); - on("queue-updated", handleQueueUpdated); - on("pick-replaced", handlePickReplaced); - on("draft-rolled-back", handleDraftRolledBack); - on("draft-state-sync", handleDraftStateSync); - - return () => { - off("pick-made", handlePickMade); - off("timer-update", handleTimerUpdate); - off("draft-paused", handleDraftPaused); - off("draft-resumed", handleDraftResumed); - off("draft-completed", handleDraftCompleted); - off("autodraft-updated", handleAutodraftUpdated); - off("team-connected", handleTeamConnected); - off("team-disconnected", handleTeamDisconnected); - off("connected-teams-list", handleConnectedTeamsList); - off("participant-removed-from-queues", handleParticipantRemovedFromQueues); - off("queue-eligibility-pruned", handleQueueEligibilityPruned); - off("queue-updated", handleQueueUpdated); - off("pick-replaced", handlePickReplaced); - off("draft-rolled-back", handleDraftRolledBack); - off("draft-state-sync", handleDraftStateSync); - }; - // `on`/`off` are stable useCallback refs. `socketVersion` increments whenever - // useDraftSocket creates a new socket instance, ensuring this effect re-runs - // and re-registers all handlers on the fresh socket. - // `draftSlots`, `userTeam`, and `season.league.name` are intentionally omitted: - // they come from useLoaderData and are immutable for the lifetime of the page load. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [on, off, socketVersion]); + useDraftSocketEvents({ + on, + off, + socketVersion, + userTeam, + draftSlots, + leagueName: season.league.name, + isRevalidatingRef, + pendingPicksDuringRevalidationRef, + pendingQueueMutationsRef, + userAutodraftRef, + sendNotificationRef, + notificationsModeRef, + setPicks, + setCurrentPick, + setIsPaused, + setIsDraftComplete, + setAutodraftStatus, + setUserAutodraft, + setConnectedTeams, + setQueue, + setTeamTimers, + }); // Persist sidebar collapsed state useEffect(() => { @@ -1012,7 +440,7 @@ export default function DraftRoom() { // queue is read directly (not via functional updater) for the duplicate check and // optimistic position, so it must be a dep. This is fine: AvailableParticipantsSection // already re-renders when queue changes (it receives queue as a prop). - }, [userTeam, queue, season.id, authFetch]); + }, [userTeam, queue, season.id, authFetch, setQueue, pendingQueueMutationsRef]); const handleRemoveFromQueue = useCallback(async (queueId: string) => { if (!userTeam) return; @@ -1050,7 +478,7 @@ export default function DraftRoom() { } finally { pendingQueueMutationsRef.current--; } - }, [userTeam, authFetch]); + }, [userTeam, authFetch, setQueue, pendingQueueMutationsRef]); const handleReorderQueue = useCallback(async (participantIds: string[]) => { if (!userTeam) return; @@ -1091,7 +519,7 @@ export default function DraftRoom() { } finally { pendingQueueMutationsRef.current--; } - }, [userTeam, season.id, authFetch]); + }, [userTeam, season.id, authFetch, setQueue, pendingQueueMutationsRef]); const handleStartDraft = async () => { try { @@ -1239,7 +667,7 @@ export default function DraftRoom() { setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id }); setReplacePickDialogOpen(true); // picks is read directly to find the pick being replaced. - }, [picks]); + }, [picks, setReplacePickSlot, setReplacePickDialogOpen]); const handleReplacePick = async (participantId: string) => { if (!replacePickSlot) return; @@ -1272,7 +700,7 @@ export default function DraftRoom() { const handleRollbackToPick = useCallback((pickNumber: number) => { setRollbackPickNumber(pickNumber); setRollbackConfirmOpen(true); - }, []); + }, [setRollbackPickNumber, setRollbackConfirmOpen]); const handleConfirmRollback = async () => { if (!rollbackPickNumber || isRollingBack) return; @@ -1310,17 +738,17 @@ export default function DraftRoom() { setTimeBankUnit("minutes"); setTimeBankDirection("add"); setTimeBankDialogOpen(true); - }, []); + }, [setTimeBankTeamId, setTimeBankAmount, setTimeBankUnit, setTimeBankDirection, setTimeBankDialogOpen]); const handleSetAutodraftOpen = useCallback((teamId: string) => { setCommissionerAutodraftTeamId(teamId); setCommissionerAutodraftDialogOpen(true); - }, []); + }, [setCommissionerAutodraftTeamId, setCommissionerAutodraftDialogOpen]); const handleCommissionerAutodraftUpdate = useCallback(() => { setCommissionerAutodraftDialogOpen(false); setCommissionerAutodraftTeamId(null); - }, []); + }, [setCommissionerAutodraftDialogOpen, setCommissionerAutodraftTeamId]); const handleConfirmAdjustTimeBank = async () => { if (!timeBankTeamId || isAdjustingTimeBank) return; @@ -1375,7 +803,7 @@ export default function DraftRoom() { const handleForceManualPickOpen = useCallback((pickNumber: number, teamId: string) => { setSelectedPickSlot({ pickNumber, teamId }); setForcePickDialogOpen(true); - }, []); + }, [setSelectedPickSlot, setForcePickDialogOpen]); // Calculate current round const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1; @@ -1504,7 +932,7 @@ export default function DraftRoom() { if (hide) { setSportFilters((prev) => prev.filter((s) => !userDraftedSportNames.has(s))); } - }, [userDraftedSportNames]); + }, [userDraftedSportNames, setHideCompletedSports, setSportFilters]); const miniDraftGrid = useMemo(() => ({ draftSlots, @@ -1546,7 +974,7 @@ export default function DraftRoom() { onMakePick: handleMakePick, onAddToQueue: handleAddToQueue, onRemoveFromQueue: handleRemoveFromQueue, - }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue]); + }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible]); const draftGridSectionProps = { draftSlots, @@ -1587,7 +1015,7 @@ export default function DraftRoom() { (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => { setUserAutodraft({ isEnabled, mode, queueOnly }); }, - [] + [setUserAutodraft] ); const queueSectionProps = userTeam @@ -1677,21 +1105,16 @@ export default function DraftRoom() { />

{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */} - {isCommissioner && season.status === "pre_draft" && ( - - )} - - {isCommissioner && season.status === "draft" && !isDraftComplete && ( + {isCommissioner && (
- {isPaused ? ( - - ) : ( - - )} +
)} @@ -1970,152 +1393,44 @@ export default function DraftRoom() { onSelect={handleReplacePick} /> + {/* Rollback Confirmation Dialog */} - { - setRollbackConfirmOpen(open); - if (!open) setRollbackPickNumber(null); - }} - > - - - Roll Back Draft - - {rollbackPickNumber !== null && (() => { - const count = picks.filter((p) => p.pickNumber >= rollbackPickNumber).length; - return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`; - })()} - - - - - - - - + onOpenChange={(open) => { setRollbackConfirmOpen(open); if (!open) setRollbackPickNumber(null); }} + pickNumber={rollbackPickNumber} + picksCount={rollbackPickNumber !== null ? picks.filter((p) => p.pickNumber >= rollbackPickNumber).length : 0} + isRollingBack={isRollingBack} + onConfirm={handleConfirmRollback} + /> {/* Time Bank Adjustment Dialog */} - { - setTimeBankDialogOpen(open); - if (!open) setTimeBankTeamId(null); - }} - > - - - Adjust Time Bank - - {timeBankTeamId - ? `Adjusting time for ${draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}` - : "Adjust a team's time bank"} - - - -
-
- - -
- -
- setTimeBankAmount(e.target.value)} - className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm" - placeholder="Amount" - /> - -
-
- - - - - -
-
+ onOpenChange={(open) => { setTimeBankDialogOpen(open); if (!open) setTimeBankTeamId(null); }} + teamName={draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name} + amount={timeBankAmount} + onAmountChange={setTimeBankAmount} + unit={timeBankUnit} + onUnitChange={setTimeBankUnit} + direction={timeBankDirection} + onDirectionChange={setTimeBankDirection} + isAdjusting={isAdjustingTimeBank} + onConfirm={handleConfirmAdjustTimeBank} + /> {/* Commissioner Autodraft Override Dialog */} - { - setCommissionerAutodraftDialogOpen(open); - if (!open) setCommissionerAutodraftTeamId(null); - }} - > - - - Set Autodraft - - {commissionerAutodraftTeamId - ? `Change autodraft for ${draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name ?? "this team"}` - : "Set a team's autodraft settings"} - - - {commissionerAutodraftTeamId && ( - - )} - - + onOpenChange={(open) => { setCommissionerAutodraftDialogOpen(open); if (!open) setCommissionerAutodraftTeamId(null); }} + teamId={commissionerAutodraftTeamId} + teamName={draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name} + seasonId={season.id} + isEnabled={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.isEnabled ?? false} + mode={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.mode ?? "next_pick"} + queueOnly={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.queueOnly ?? false} + onUpdate={handleCommissionerAutodraftUpdate} + /> {/* Connection Overlay - blocks interaction until socket connects */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Finish - - Points -
1st Place - 100 -
2nd Place - 70 -
3rd Place - 50 -
4th Place - 40 -
5th–6th Place - 25 -
7th–8th Place - 15 -
-
+

@@ -175,70 +126,7 @@ export default function Rules() { based on their finish:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Finish - - Qualifying Points -
1st - 20 QP -
2nd - 14 QP -
3rd - 10 QP -
4th - 8 QP -
5th–6th - 5 QP -
7th–8th - 3 QP -
9th–12th - 2 QP -
13th–16th - 1 QP -
-
+

Qualifying points are not added to a participant's total league