Add Storybook stories for new components from staged changes (#321)
This commit is contained in:
parent
41d45041c8
commit
01dacbce27
58 changed files with 3185 additions and 1807 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
71
app/components/TeamAvatar.stories.tsx
Normal file
71
app/components/TeamAvatar.stories.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TeamAvatar } from "./TeamAvatar";
|
||||
|
||||
const meta: Meta<typeof TeamAvatar> = {
|
||||
title: "UI/TeamAvatar",
|
||||
component: TeamAvatar,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TeamAvatar>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
teamId: "team-1",
|
||||
teamName: "Thunder Hawks",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
teamId: "team-1",
|
||||
teamName: "Thunder Hawks",
|
||||
size: "sm",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
teamId: "team-1",
|
||||
teamName: "Thunder Hawks",
|
||||
size: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLogo: Story = {
|
||||
args: {
|
||||
teamId: "team-2",
|
||||
teamName: "Iron Eagles",
|
||||
logoUrl: "https://placehold.co/48x48/1a1a2e/e0e0e0?text=IE",
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleWordName: Story = {
|
||||
args: {
|
||||
teamId: "team-3",
|
||||
teamName: "Superteam",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
name: "All Sizes — Side by Side",
|
||||
render: () => (
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="sm" />
|
||||
<span className="text-xs text-muted-foreground">sm</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="md" />
|
||||
<span className="text-xs text-muted-foreground">md</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="lg" />
|
||||
<span className="text-xs text-muted-foreground">lg</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
|
@ -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+/)
|
||||
|
|
|
|||
70
app/components/draft/CommissionerAutodraftDialog.stories.tsx
Normal file
70
app/components/draft/CommissionerAutodraftDialog.stories.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CommissionerAutodraftDialog } from "./CommissionerAutodraftDialog";
|
||||
|
||||
const meta: Meta<typeof CommissionerAutodraftDialog> = {
|
||||
title: "Draft/CommissionerAutodraftDialog",
|
||||
component: CommissionerAutodraftDialog,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CommissionerAutodraftDialog>;
|
||||
|
||||
export const AutodraftOff: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamId: "team-1",
|
||||
teamName: "Thunder Hawks",
|
||||
seasonId: "season-1",
|
||||
isEnabled: false,
|
||||
mode: "next_pick",
|
||||
queueOnly: false,
|
||||
onUpdate: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const AutodraftNextQueue: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamId: "team-1",
|
||||
teamName: "Shadow Hawks",
|
||||
seasonId: "season-1",
|
||||
isEnabled: true,
|
||||
mode: "next_pick",
|
||||
queueOnly: true,
|
||||
onUpdate: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const AutodraftAllPicks: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamId: "team-2",
|
||||
teamName: "Iron Eagles",
|
||||
seasonId: "season-1",
|
||||
isEnabled: true,
|
||||
mode: "while_on",
|
||||
queueOnly: false,
|
||||
onUpdate: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoTeamSelected: Story = {
|
||||
name: "No Team Selected (body hidden)",
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamId: null,
|
||||
teamName: undefined,
|
||||
seasonId: "season-1",
|
||||
isEnabled: false,
|
||||
mode: "next_pick",
|
||||
queueOnly: false,
|
||||
onUpdate: () => {},
|
||||
},
|
||||
};
|
||||
57
app/components/draft/CommissionerAutodraftDialog.tsx
Normal file
57
app/components/draft/CommissionerAutodraftDialog.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
|
||||
interface CommissionerAutodraftDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
teamId: string | null;
|
||||
teamName: string | undefined;
|
||||
seasonId: string;
|
||||
isEnabled: boolean;
|
||||
mode: "next_pick" | "while_on";
|
||||
queueOnly: boolean;
|
||||
onUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => void;
|
||||
}
|
||||
|
||||
export function CommissionerAutodraftDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
teamId,
|
||||
teamName,
|
||||
seasonId,
|
||||
isEnabled,
|
||||
mode,
|
||||
queueOnly,
|
||||
onUpdate,
|
||||
}: CommissionerAutodraftDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Autodraft</DialogTitle>
|
||||
<DialogDescription>
|
||||
{teamId
|
||||
? `Change autodraft for ${teamName ?? "this team"}`
|
||||
: "Set a team's autodraft settings"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{teamId && (
|
||||
<AutodraftSettings
|
||||
seasonId={seasonId}
|
||||
teamId={teamId}
|
||||
isEnabled={isEnabled}
|
||||
mode={mode}
|
||||
queueOnly={queueOnly}
|
||||
isMyTurn={false}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
58
app/components/draft/CommissionerDraftControls.stories.tsx
Normal file
58
app/components/draft/CommissionerDraftControls.stories.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CommissionerDraftControls } from "./CommissionerDraftControls";
|
||||
|
||||
const meta: Meta<typeof CommissionerDraftControls> = {
|
||||
title: "Draft/CommissionerDraftControls",
|
||||
component: CommissionerDraftControls,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CommissionerDraftControls>;
|
||||
|
||||
export const PreDraft: Story = {
|
||||
args: {
|
||||
seasonStatus: "pre_draft",
|
||||
isDraftComplete: false,
|
||||
isPaused: false,
|
||||
onStart: () => {},
|
||||
onPause: () => {},
|
||||
onResume: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const DraftActive: Story = {
|
||||
args: {
|
||||
seasonStatus: "draft",
|
||||
isDraftComplete: false,
|
||||
isPaused: false,
|
||||
onStart: () => {},
|
||||
onPause: () => {},
|
||||
onResume: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const DraftPaused: Story = {
|
||||
args: {
|
||||
seasonStatus: "draft",
|
||||
isDraftComplete: false,
|
||||
isPaused: true,
|
||||
onStart: () => {},
|
||||
onPause: () => {},
|
||||
onResume: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const DraftComplete: Story = {
|
||||
name: "Draft Complete (renders null)",
|
||||
args: {
|
||||
seasonStatus: "draft",
|
||||
isDraftComplete: true,
|
||||
isPaused: false,
|
||||
onStart: () => {},
|
||||
onPause: () => {},
|
||||
onResume: () => {},
|
||||
},
|
||||
};
|
||||
43
app/components/draft/CommissionerDraftControls.tsx
Normal file
43
app/components/draft/CommissionerDraftControls.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface CommissionerDraftControlsProps {
|
||||
seasonStatus: string;
|
||||
isDraftComplete: boolean;
|
||||
isPaused: boolean;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
export function CommissionerDraftControls({
|
||||
seasonStatus,
|
||||
isDraftComplete,
|
||||
isPaused,
|
||||
onStart,
|
||||
onPause,
|
||||
onResume,
|
||||
buttonClassName,
|
||||
}: CommissionerDraftControlsProps) {
|
||||
if (seasonStatus === "pre_draft") {
|
||||
return (
|
||||
<Button className={buttonClassName} onClick={onStart}>
|
||||
Start Draft
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (seasonStatus === "draft" && !isDraftComplete) {
|
||||
return isPaused ? (
|
||||
<Button className={buttonClassName} onClick={onResume}>
|
||||
Resume Draft
|
||||
</Button>
|
||||
) : (
|
||||
<Button className={buttonClassName} variant="outline" onClick={onPause}>
|
||||
Pause Draft
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -238,3 +238,26 @@ export const CoronaStatesRow: Story = {
|
|||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const CurrentPreDraft: Story = {
|
||||
name: "Current — Pre-Draft (Up Next)",
|
||||
args: {
|
||||
pickNumber: 1,
|
||||
round: 1,
|
||||
pickInRound: 1,
|
||||
state: "current",
|
||||
seasonStatus: "pre_draft",
|
||||
},
|
||||
};
|
||||
|
||||
export const CurrentPausedDraft: Story = {
|
||||
name: "Current — Paused Draft",
|
||||
args: {
|
||||
pickNumber: 1,
|
||||
round: 1,
|
||||
pickInRound: 1,
|
||||
state: "current",
|
||||
seasonStatus: "draft",
|
||||
draftPaused: true,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { forwardRef, type CSSProperties, type ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { SeasonStatus } from "~/models/season";
|
||||
|
||||
export type CoronaState =
|
||||
| { type: "eliminated"; points: 0 }
|
||||
| { type: "pending" }
|
||||
| { type: "scored"; brightness: number; points: number };
|
||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
||||
export type { CoronaState } from "~/lib/corona-states";
|
||||
import type { CoronaState } from "~/lib/corona-states";
|
||||
|
||||
type CoronaVariant =
|
||||
| "gradient"
|
||||
|
|
@ -30,7 +28,7 @@ type DraftPickCellProps = ComponentPropsWithoutRef<"div"> & {
|
|||
};
|
||||
|
||||
const coronaClasses: Record<CoronaVariant, string> = {
|
||||
gradient: "[background:linear-gradient(to_bottom,#adf661,#2ce1c1)]",
|
||||
gradient: "[background:var(--brackt-gradient)]",
|
||||
electric: "bg-electric",
|
||||
emerald: "bg-emerald-500",
|
||||
amber: "bg-amber-accent",
|
||||
|
|
@ -45,7 +43,7 @@ function getCoronaStyle(cs: CoronaState): CSSProperties {
|
|||
return { background: "rgba(255, 255, 255, 0.08)" };
|
||||
case "scored":
|
||||
return {
|
||||
background: "linear-gradient(to bottom, #adf661, #2ce1c1)",
|
||||
background: BRACKT_GRADIENT,
|
||||
opacity: cs.brightness,
|
||||
};
|
||||
}
|
||||
|
|
@ -86,7 +84,7 @@ function DraftPickCell({
|
|||
{...rest}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<div className="absolute inset-0 rounded-lg [background:linear-gradient(to_bottom,#adf661,#2ce1c1)] animate-pulse-gradient" />
|
||||
<div className="absolute inset-0 rounded-lg [background:var(--brackt-gradient)] animate-pulse-gradient" />
|
||||
) : coronaState ? (
|
||||
<div
|
||||
className="absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg"
|
||||
|
|
@ -146,3 +144,4 @@ function DraftPickCell({
|
|||
</div>
|
||||
);
|
||||
});
|
||||
DraftPickCell.displayName = "DraftPickCell";
|
||||
|
|
|
|||
58
app/components/draft/RollbackConfirmDialog.stories.tsx
Normal file
58
app/components/draft/RollbackConfirmDialog.stories.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RollbackConfirmDialog } from "./RollbackConfirmDialog";
|
||||
|
||||
const meta: Meta<typeof RollbackConfirmDialog> = {
|
||||
title: "Draft/RollbackConfirmDialog",
|
||||
component: RollbackConfirmDialog,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RollbackConfirmDialog>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
pickNumber: 5,
|
||||
picksCount: 3,
|
||||
isRollingBack: false,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const SinglePick: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
pickNumber: 7,
|
||||
picksCount: 1,
|
||||
isRollingBack: false,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RollingBack: Story = {
|
||||
name: "Rolling Back...",
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
pickNumber: 3,
|
||||
picksCount: 5,
|
||||
isRollingBack: true,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Closed: Story = {
|
||||
args: {
|
||||
open: false,
|
||||
onOpenChange: () => {},
|
||||
pickNumber: null,
|
||||
picksCount: 0,
|
||||
isRollingBack: false,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
42
app/components/draft/RollbackConfirmDialog.tsx
Normal file
42
app/components/draft/RollbackConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Button } from "~/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
|
||||
interface RollbackConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pickNumber: number | null;
|
||||
picksCount: number;
|
||||
isRollingBack: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function RollbackConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pickNumber,
|
||||
picksCount,
|
||||
isRollingBack,
|
||||
onConfirm,
|
||||
}: RollbackConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Roll Back Draft</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pickNumber !== null &&
|
||||
`This will erase ${picksCount} pick${picksCount === 1 ? "" : "s"} (pick #${pickNumber} through the most recent) and return the draft to pick #${pickNumber}. This cannot be undone.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={isRollingBack}>
|
||||
{isRollingBack ? "Rolling Back..." : "Roll Back"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
95
app/components/draft/TimeBankAdjustmentDialog.stories.tsx
Normal file
95
app/components/draft/TimeBankAdjustmentDialog.stories.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TimeBankAdjustmentDialog } from "./TimeBankAdjustmentDialog";
|
||||
|
||||
const meta: Meta<typeof TimeBankAdjustmentDialog> = {
|
||||
title: "Draft/TimeBankAdjustmentDialog",
|
||||
component: TimeBankAdjustmentDialog,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TimeBankAdjustmentDialog>;
|
||||
|
||||
export const AddTime: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamName: "Thunder Hawks",
|
||||
amount: "30",
|
||||
onAmountChange: () => {},
|
||||
unit: "seconds",
|
||||
onUnitChange: () => {},
|
||||
direction: "add",
|
||||
onDirectionChange: () => {},
|
||||
isAdjusting: false,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RemoveTime: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamName: "Shadow Hawks",
|
||||
amount: "2",
|
||||
onAmountChange: () => {},
|
||||
unit: "minutes",
|
||||
onUnitChange: () => {},
|
||||
direction: "remove",
|
||||
onDirectionChange: () => {},
|
||||
isAdjusting: false,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Adjusting: Story = {
|
||||
name: "Adjusting...",
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamName: "Iron Eagles",
|
||||
amount: "1",
|
||||
onAmountChange: () => {},
|
||||
unit: "hours",
|
||||
onUnitChange: () => {},
|
||||
direction: "add",
|
||||
onDirectionChange: () => {},
|
||||
isAdjusting: true,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoTeamName: Story = {
|
||||
name: "No Team Name",
|
||||
args: {
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
teamName: undefined,
|
||||
amount: "15",
|
||||
onAmountChange: () => {},
|
||||
unit: "seconds",
|
||||
onUnitChange: () => {},
|
||||
direction: "add",
|
||||
onDirectionChange: () => {},
|
||||
isAdjusting: false,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Closed: Story = {
|
||||
args: {
|
||||
open: false,
|
||||
onOpenChange: () => {},
|
||||
teamName: undefined,
|
||||
amount: "",
|
||||
onAmountChange: () => {},
|
||||
unit: "seconds",
|
||||
onUnitChange: () => {},
|
||||
direction: "add",
|
||||
onDirectionChange: () => {},
|
||||
isAdjusting: false,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
104
app/components/draft/TimeBankAdjustmentDialog.tsx
Normal file
104
app/components/draft/TimeBankAdjustmentDialog.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { Button } from "~/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
|
||||
interface TimeBankAdjustmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
teamName: string | undefined;
|
||||
amount: string;
|
||||
onAmountChange: (value: string) => void;
|
||||
unit: "seconds" | "minutes" | "hours";
|
||||
onUnitChange: (value: "seconds" | "minutes" | "hours") => void;
|
||||
direction: "add" | "remove";
|
||||
onDirectionChange: (value: "add" | "remove") => void;
|
||||
isAdjusting: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function TimeBankAdjustmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
teamName,
|
||||
amount,
|
||||
onAmountChange,
|
||||
unit,
|
||||
onUnitChange,
|
||||
direction,
|
||||
onDirectionChange,
|
||||
isAdjusting,
|
||||
onConfirm,
|
||||
}: TimeBankAdjustmentDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adjust Time Bank</DialogTitle>
|
||||
<DialogDescription>
|
||||
{teamName ? `Adjusting time for ${teamName}` : "Adjust a team's time bank"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDirectionChange("add")}
|
||||
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
||||
direction === "add"
|
||||
? "bg-emerald-500/20 border-emerald-500 text-emerald-400"
|
||||
: "border-border text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
Add Time
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDirectionChange("remove")}
|
||||
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
||||
direction === "remove"
|
||||
? "bg-destructive/20 border-destructive text-destructive"
|
||||
: "border-border text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
Remove Time
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="any"
|
||||
value={amount}
|
||||
onChange={(e) => onAmountChange(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
|
||||
placeholder="Amount"
|
||||
/>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => onUnitChange(e.target.value as "seconds" | "minutes" | "hours")}
|
||||
className="px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
|
||||
>
|
||||
<option value="seconds">Seconds</option>
|
||||
<option value="minutes">Minutes</option>
|
||||
<option value="hours">Hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isAdjusting}
|
||||
variant={direction === "remove" ? "destructive" : "default"}
|
||||
>
|
||||
{isAdjusting ? "Adjusting..." : direction === "add" ? "Add Time" : "Remove Time"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
65
app/components/league/LeagueAvatar.stories.tsx
Normal file
65
app/components/league/LeagueAvatar.stories.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LeagueAvatar } from "./LeagueAvatar";
|
||||
|
||||
const meta: Meta<typeof LeagueAvatar> = {
|
||||
title: "League/LeagueAvatar",
|
||||
component: LeagueAvatar,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LeagueAvatar>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
leagueId: "league-1",
|
||||
leagueName: "Friday Night Ballers",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
leagueId: "league-1",
|
||||
leagueName: "Friday Night Ballers",
|
||||
className: "h-5 w-5",
|
||||
textClassName: "text-[10px]",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
leagueId: "league-1",
|
||||
leagueName: "Friday Night Ballers",
|
||||
className: "h-16 w-16",
|
||||
textClassName: "text-xl",
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleWordName: Story = {
|
||||
args: {
|
||||
leagueId: "league-2",
|
||||
leagueName: "Superteam",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
name: "All Sizes — Side by Side",
|
||||
render: () => (
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LeagueAvatar leagueId="league-1" leagueName="Friday Night Ballers" className="h-5 w-5" textClassName="text-[10px]" />
|
||||
<span className="text-xs text-muted-foreground">sm</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LeagueAvatar leagueId="league-1" leagueName="Friday Night Ballers" />
|
||||
<span className="text-xs text-muted-foreground">default</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LeagueAvatar leagueId="league-1" leagueName="Friday Night Ballers" className="h-16 w-16" textClassName="text-xl" />
|
||||
<span className="text-xs text-muted-foreground">lg</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
|
@ -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+/)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function TeamsPanel({ teams, currentUserId, ownerMap, draftSlots }: Teams
|
|||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-white/[0.08] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-[#adf661] to-[#2ce1c1] transition-all"
|
||||
className="h-full rounded-full bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] transition-all"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
36
app/components/marketing/BracketDecor.stories.tsx
Normal file
36
app/components/marketing/BracketDecor.stories.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BracketDecor } from "./BracketDecor";
|
||||
|
||||
const meta: Meta<typeof BracketDecor> = {
|
||||
title: "Marketing/BracketDecor",
|
||||
component: BracketDecor,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="relative h-[75vh] w-full bg-black overflow-hidden">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BracketDecor>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Flipped: Story = {
|
||||
args: {
|
||||
flip: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileTall: Story = {
|
||||
args: {
|
||||
mobileTall: true,
|
||||
},
|
||||
};
|
||||
137
app/components/marketing/BracketDecor.tsx
Normal file
137
app/components/marketing/BracketDecor.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { BRACKT_GRADIENT_START, BRACKT_GRADIENT_END } from "~/lib/brand";
|
||||
|
||||
export function BracketDecor({ flip = false, mobileTall = false }: { flip?: boolean; mobileTall?: boolean }) {
|
||||
const base = flip ? "r" : "l";
|
||||
const id = mobileTall ? `${base}-m` : base;
|
||||
const W = 399;
|
||||
const H = mobileTall ? 2000 : 560;
|
||||
|
||||
const pad = 36;
|
||||
const spacing = (H - 2 * pad) / 7;
|
||||
const r1Ys = Array.from({ length: 8 }, (_, i) => pad + i * spacing);
|
||||
const r2Ys = [0, 1, 2, 3].map((i) => (r1Ys[i * 2] + r1Ys[i * 2 + 1]) / 2);
|
||||
const r3Ys = [0, 1].map((i) => (r2Ys[i * 2] + r2Ys[i * 2 + 1]) / 2);
|
||||
const champY = (r3Ys[0] + r3Ys[1]) / 2;
|
||||
|
||||
const x0 = 4;
|
||||
const x1 = 77;
|
||||
const x2 = 161;
|
||||
const x3 = 252;
|
||||
|
||||
const gradId = `grad-${id}`;
|
||||
const glowId = `glow-${id}`;
|
||||
const dotsGlowId = `dots-glow-${id}`;
|
||||
const finalsGlowId = `finals-glow-${id}`;
|
||||
const finalsHaloId = `finals-halo-${id}`;
|
||||
const finalsFadeMaskGradId = `finals-fade-mask-grad-${id}`;
|
||||
const finalsFadeMaskId = `finals-fade-mask-${id}`;
|
||||
const finalsLineGlowId = `finals-line-glow-${id}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: `translateY(-52%)${flip ? " scaleX(-1)" : ""}`,
|
||||
[flip ? "right" : "left"]: 0,
|
||||
pointerEvents: "none",
|
||||
height: mobileTall ? "auto" : "75vh",
|
||||
width: mobileTall ? "32vw" : "auto",
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2={H} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={BRACKT_GRADIENT_START} />
|
||||
<stop offset="100%" stopColor={BRACKT_GRADIENT_END} />
|
||||
</linearGradient>
|
||||
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id={finalsGlowId} filterUnits="userSpaceOnUse"
|
||||
x={x2 - 15} y={r3Ys[0] - 15} width={x3 - x2 + 30} height={r3Ys[1] - r3Ys[0] + 30}>
|
||||
<feGaussianBlur stdDeviation="7" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id={finalsHaloId} filterUnits="userSpaceOnUse"
|
||||
x={x2 - 40} y={r3Ys[0] - 40} width={x3 - x2 + 80} height={r3Ys[1] - r3Ys[0] + 80}>
|
||||
<feGaussianBlur stdDeviation="16" />
|
||||
</filter>
|
||||
<linearGradient id={finalsFadeMaskGradId} x1={x3} y1="0" x2={W} y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="white" />
|
||||
<stop offset="50%" stopColor="white" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<mask id={finalsFadeMaskId} maskUnits="userSpaceOnUse" x={x3} y={champY - 20} width={W - x3} height={40}>
|
||||
<rect x="0" y="0" width={W} height={H} fill={`url(#${finalsFadeMaskGradId})`} />
|
||||
</mask>
|
||||
<filter id={finalsLineGlowId} filterUnits="userSpaceOnUse" x={x3 - 10} y={champY - 15} width={W - x3 + 20} height={30}>
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id={dotsGlowId} x="-200%" y="-200%" width="500%" height="500%">
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${finalsHaloId})`} stroke={`url(#${gradId})`} strokeWidth={2} fill="none" opacity={0.75}>
|
||||
{r3Ys.map((midY) => (
|
||||
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
|
||||
))}
|
||||
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
|
||||
</g>
|
||||
<g filter={`url(#${finalsGlowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
|
||||
{r3Ys.map((midY) => (
|
||||
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
|
||||
))}
|
||||
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
|
||||
</g>
|
||||
<g filter={`url(#${glowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
|
||||
{r1Ys.map((y) => (
|
||||
<g key={y}>
|
||||
<circle cx={x0} cy={y} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
<line x1={x0} y1={y} x2={x1} y2={y} />
|
||||
</g>
|
||||
))}
|
||||
{r2Ys.map((midY, i) => (
|
||||
<g key={midY}>
|
||||
<line x1={x1} y1={r1Ys[i * 2]} x2={x1} y2={r1Ys[i * 2 + 1]} />
|
||||
<circle cx={x1} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
<line x1={x1} y1={midY} x2={x2} y2={midY} />
|
||||
</g>
|
||||
))}
|
||||
{r3Ys.map((midY, i) => (
|
||||
<g key={midY}>
|
||||
<line x1={x2} y1={r2Ys[i * 2]} x2={x2} y2={r2Ys[i * 2 + 1]} />
|
||||
<circle cx={x2} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
<line x1={x2} y1={midY} x2={x3} y2={midY} />
|
||||
</g>
|
||||
))}
|
||||
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
|
||||
<circle cx={x3} cy={champY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
</g>
|
||||
<line
|
||||
x1={x3} y1={champY} x2={W} y2={champY}
|
||||
stroke={`url(#${gradId})`}
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
filter={`url(#${finalsLineGlowId})`}
|
||||
mask={`url(#${finalsFadeMaskId})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Users, Shuffle, Trophy, ChevronDown } from "lucide-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
|
||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
||||
import { BracketDecor } from "./BracketDecor";
|
||||
import { SPORTS, HOW_IT_WORKS_STEPS, FEATURES } from "~/lib/landing-data";
|
||||
|
||||
// ─── Slot Machine ────────────────────────────────────────────────────────────
|
||||
|
||||
const SPORTS = [
|
||||
"Football", "Baseball", "Hockey", "Basketball", "Soccer", "Tennis", "Golf",
|
||||
"eSports", "Formula 1", "Aussie Rules", "Lacrosse", "Darts", "College Football", "Snooker", "March Madness",
|
||||
];
|
||||
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
|
|
@ -66,7 +64,7 @@ function SlotMachineHeadline() {
|
|||
return (
|
||||
<span
|
||||
className="animate-sport-land block bg-clip-text text-transparent pb-[0.12em]"
|
||||
style={{ backgroundImage: "linear-gradient(to bottom, #adf661, #2ce1c1)" }}
|
||||
style={{ backgroundImage: BRACKT_GRADIENT }}
|
||||
>
|
||||
Every Sport.
|
||||
</span>
|
||||
|
|
@ -80,145 +78,6 @@ function SlotMachineHeadline() {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Bracket Decoration ──────────────────────────────────────────────────────
|
||||
|
||||
function BracketDecor({ flip = false, mobileTall = false }: { flip?: boolean; mobileTall?: boolean }) {
|
||||
const base = flip ? "r" : "l";
|
||||
const id = mobileTall ? `${base}-m` : base;
|
||||
const W = 399;
|
||||
const H = mobileTall ? 2000 : 560;
|
||||
|
||||
const pad = 36;
|
||||
const spacing = (H - 2 * pad) / 7;
|
||||
const r1Ys = Array.from({ length: 8 }, (_, i) => pad + i * spacing);
|
||||
const r2Ys = [0, 1, 2, 3].map((i) => (r1Ys[i * 2] + r1Ys[i * 2 + 1]) / 2);
|
||||
const r3Ys = [0, 1].map((i) => (r2Ys[i * 2] + r2Ys[i * 2 + 1]) / 2);
|
||||
const champY = (r3Ys[0] + r3Ys[1]) / 2;
|
||||
|
||||
const x0 = 4;
|
||||
const x1 = 77;
|
||||
const x2 = 161;
|
||||
const x3 = 252;
|
||||
|
||||
const gradId = `grad-${id}`;
|
||||
const glowId = `glow-${id}`;
|
||||
const dotsGlowId = `dots-glow-${id}`;
|
||||
const finalsGlowId = `finals-glow-${id}`;
|
||||
const finalsHaloId = `finals-halo-${id}`;
|
||||
const finalsFadeMaskGradId = `finals-fade-mask-grad-${id}`;
|
||||
const finalsFadeMaskId = `finals-fade-mask-${id}`;
|
||||
const finalsLineGlowId = `finals-line-glow-${id}`;
|
||||
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: `translateY(-52%)${flip ? " scaleX(-1)" : ""}`,
|
||||
[flip ? "right" : "left"]: 0,
|
||||
pointerEvents: "none",
|
||||
height: mobileTall ? "auto" : "75vh",
|
||||
width: mobileTall ? "32vw" : "auto",
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2={H} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#adf661" />
|
||||
<stop offset="100%" stopColor="#2ce1c1" />
|
||||
</linearGradient>
|
||||
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id={finalsGlowId} filterUnits="userSpaceOnUse"
|
||||
x={x2 - 15} y={r3Ys[0] - 15} width={x3 - x2 + 30} height={r3Ys[1] - r3Ys[0] + 30}>
|
||||
<feGaussianBlur stdDeviation="7" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id={finalsHaloId} filterUnits="userSpaceOnUse"
|
||||
x={x2 - 40} y={r3Ys[0] - 40} width={x3 - x2 + 80} height={r3Ys[1] - r3Ys[0] + 80}>
|
||||
<feGaussianBlur stdDeviation="16" />
|
||||
</filter>
|
||||
<linearGradient id={finalsFadeMaskGradId} x1={x3} y1="0" x2={W} y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="white" />
|
||||
<stop offset="50%" stopColor="white" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<mask id={finalsFadeMaskId} maskUnits="userSpaceOnUse" x={x3} y={champY - 20} width={W - x3} height={40}>
|
||||
<rect x="0" y="0" width={W} height={H} fill={`url(#${finalsFadeMaskGradId})`} />
|
||||
</mask>
|
||||
<filter id={finalsLineGlowId} filterUnits="userSpaceOnUse" x={x3 - 10} y={champY - 15} width={W - x3 + 20} height={30}>
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id={dotsGlowId} x="-200%" y="-200%" width="500%" height="500%">
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${finalsHaloId})`} stroke={`url(#${gradId})`} strokeWidth={2} fill="none" opacity={0.75}>
|
||||
{r3Ys.map((midY) => (
|
||||
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
|
||||
))}
|
||||
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
|
||||
</g>
|
||||
<g filter={`url(#${finalsGlowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
|
||||
{r3Ys.map((midY) => (
|
||||
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
|
||||
))}
|
||||
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
|
||||
</g>
|
||||
<g filter={`url(#${glowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
|
||||
{r1Ys.map((y) => (
|
||||
<g key={y}>
|
||||
<circle cx={x0} cy={y} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
<line x1={x0} y1={y} x2={x1} y2={y} />
|
||||
</g>
|
||||
))}
|
||||
{r2Ys.map((midY, i) => (
|
||||
<g key={midY}>
|
||||
<line x1={x1} y1={r1Ys[i * 2]} x2={x1} y2={r1Ys[i * 2 + 1]} />
|
||||
<circle cx={x1} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
<line x1={x1} y1={midY} x2={x2} y2={midY} />
|
||||
</g>
|
||||
))}
|
||||
{r3Ys.map((midY, i) => (
|
||||
<g key={midY}>
|
||||
<line x1={x2} y1={r2Ys[i * 2]} x2={x2} y2={r2Ys[i * 2 + 1]} />
|
||||
<circle cx={x2} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
<line x1={x2} y1={midY} x2={x3} y2={midY} />
|
||||
</g>
|
||||
))}
|
||||
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
|
||||
<circle cx={x3} cy={champY} r={3} fill={`url(#${gradId})`} stroke="none" filter={`url(#${dotsGlowId})`} />
|
||||
</g>
|
||||
<line
|
||||
x1={x3} y1={champY} x2={W} y2={champY}
|
||||
stroke={`url(#${gradId})`}
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
filter={`url(#${finalsLineGlowId})`}
|
||||
mask={`url(#${finalsFadeMaskId})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scroll Hint ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ScrollHint() {
|
||||
|
|
@ -270,45 +129,6 @@ function SportTicker() {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Static data ──────────────────────────────────────────────────────────────
|
||||
|
||||
const HOW_IT_WORKS_STEPS = [
|
||||
{
|
||||
icon: Users,
|
||||
title: "Create a League",
|
||||
body: "Start a league, pick your sports (we can help), and invite your friends. It takes 30 seconds to set the stage.",
|
||||
},
|
||||
{
|
||||
icon: Shuffle,
|
||||
title: "Draft Your Teams",
|
||||
body: "Pick teams from all the sports in order to build a superteam to win you bragging rights! Draft your way, slow or fast, with standard timers or high-pressure chess clocks.",
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: "Watch & Win",
|
||||
body: "Your teams earn points for how well they perform in the playoffs. Follow your rooting interests along the way, and gloat over your leaguemates when they fall.",
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: "Dozens of Sports and Counting",
|
||||
body: "Draft from a massive selection of sports including major US leagues, college athletics, and global niches like professional darts or snooker. We are constantly adding new sports to ensure your league has competitive action throughout the entire year.",
|
||||
},
|
||||
{
|
||||
title: "The Smart Draft Room",
|
||||
body: "Our interface supports any pace. Use a tactical chess clock with Fischer increments to reward fast decisions, or run a multi-day slow draft with customizable overnight pauses. The system ensures no one is forced to make a pick in the middle of the night.",
|
||||
},
|
||||
{
|
||||
title: "Your Master Calendar",
|
||||
body: "Stop switching between different apps to track your roster. Every game, match, and tournament for your specific teams is synced into a single view. You will always know exactly when your points are on the line without any manual tracking.",
|
||||
},
|
||||
{
|
||||
title: "Zero Maintenance",
|
||||
body: "Brackt is a draft and done experience. Once the draft is finished, your work is over because we handle all scoring and rankings automatically. You can even paste a Discord webhook URL to get live scoring updates sent directly to your group chat.",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Main Landing Page ────────────────────────────────────────────────────────
|
||||
|
||||
export function LandingPage() {
|
||||
|
|
|
|||
33
app/components/marketing/ScoringTables.stories.tsx
Normal file
33
app/components/marketing/ScoringTables.stories.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ScoringPointsTable, QualifyingPointsTable } from "./ScoringTables";
|
||||
|
||||
const meta: Meta<typeof ScoringPointsTable> = {
|
||||
title: "Marketing/ScoringTables",
|
||||
component: ScoringPointsTable,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ScoringPointsTable>;
|
||||
|
||||
export const ScoringPoints: Story = {
|
||||
render: (args) => <ScoringPointsTable {...args} />,
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const QualifyingPoints: Story = {
|
||||
render: (args) => <QualifyingPointsTable {...args} />,
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const BothTables: Story = {
|
||||
name: "Both Tables — Side by Side",
|
||||
render: () => (
|
||||
<div className="flex flex-col sm:flex-row gap-4 max-w-2xl">
|
||||
<ScoringPointsTable className="flex-1" />
|
||||
<QualifyingPointsTable className="flex-1" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
51
app/components/marketing/ScoringTables.tsx
Normal file
51
app/components/marketing/ScoringTables.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
interface TableProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoringPointsTable({ className }: TableProps) {
|
||||
return (
|
||||
<div className={`bg-muted rounded-lg overflow-hidden ${className ?? ""}`}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-3 font-semibold text-foreground">Finish</th>
|
||||
<th className="text-right p-3 font-semibold text-foreground">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr><td className="p-3">1st Place</td><td className="p-3 text-right font-semibold text-foreground">100</td></tr>
|
||||
<tr><td className="p-3">2nd Place</td><td className="p-3 text-right font-semibold text-foreground">70</td></tr>
|
||||
<tr><td className="p-3">3rd Place</td><td className="p-3 text-right font-semibold text-foreground">50</td></tr>
|
||||
<tr><td className="p-3">4th Place</td><td className="p-3 text-right font-semibold text-foreground">40</td></tr>
|
||||
<tr><td className="p-3">5th–6th Place</td><td className="p-3 text-right font-semibold text-foreground">25</td></tr>
|
||||
<tr><td className="p-3">7th–8th Place</td><td className="p-3 text-right font-semibold text-foreground">15</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QualifyingPointsTable({ className }: TableProps) {
|
||||
return (
|
||||
<div className={`bg-muted rounded-lg overflow-hidden ${className ?? ""}`}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-3 font-semibold text-foreground">Major Finish</th>
|
||||
<th className="text-right p-3 font-semibold text-foreground">Qualifying Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr><td className="p-3">1st</td><td className="p-3 text-right font-semibold text-foreground">20 QP</td></tr>
|
||||
<tr><td className="p-3">2nd</td><td className="p-3 text-right font-semibold text-foreground">14 QP</td></tr>
|
||||
<tr><td className="p-3">3rd</td><td className="p-3 text-right font-semibold text-foreground">10 QP</td></tr>
|
||||
<tr><td className="p-3">4th</td><td className="p-3 text-right font-semibold text-foreground">8 QP</td></tr>
|
||||
<tr><td className="p-3">5th–6th</td><td className="p-3 text-right font-semibold text-foreground">5 QP</td></tr>
|
||||
<tr><td className="p-3">7th–8th</td><td className="p-3 text-right font-semibold text-foreground">3 QP</td></tr>
|
||||
<tr><td className="p-3">9th–12th</td><td className="p-3 text-right font-semibold text-foreground">2 QP</td></tr>
|
||||
<tr><td className="p-3">13th–16th</td><td className="p-3 text-right font-semibold text-foreground">1 QP</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,8 +28,8 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
<img src={logoUrl} alt="Brackt" className="h-14" />
|
||||
</Link>
|
||||
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
|
||||
<Link to="/how-to-play" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] px-3 py-2">How To Play</Link>
|
||||
<Link to="/rules" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] px-3 py-2">Rules</Link>
|
||||
<Link to="/how-to-play" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:[background:var(--brackt-gradient)] px-3 py-2">How To Play</Link>
|
||||
<Link to="/rules" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:[background:var(--brackt-gradient)] px-3 py-2">Rules</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
172
app/components/scoring/BracketTreePaginated.tsx
Normal file
172
app/components/scoring/BracketTreePaginated.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
SLOT_WIDTH,
|
||||
LABEL_HEIGHT,
|
||||
DESIRED_CARD_HEIGHT,
|
||||
CARD_GAP,
|
||||
MAX_CARD_HEIGHT,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
|
||||
interface BracketTreePaginatedProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
thirdPlaceRound,
|
||||
}: BracketTreePaginatedProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
),
|
||||
);
|
||||
|
||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||
mainRounds.length - 2,
|
||||
defaultPage,
|
||||
);
|
||||
|
||||
const targetPage = anim ? anim.toPage : page;
|
||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||
|
||||
const calcHeight = (p: number) => {
|
||||
const rs = mainRounds.slice(p, p + 2);
|
||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
};
|
||||
|
||||
const pageHeight = calcHeight(page);
|
||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
|
||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
}
|
||||
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page - 1)}
|
||||
disabled={page === 0 || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Previous rounds"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
|
||||
{label}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
|
||||
<div
|
||||
ref={anim?.phase === "sliding" ? stripRef : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
|
||||
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
|
||||
}}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={leftHeight}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={rightHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{thirdPlaceMatch && (
|
||||
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
|
||||
<div
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
|
||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
3rd Place
|
||||
</div>
|
||||
<BracketMatchSlot
|
||||
match={thirdPlaceMatch}
|
||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { avatarColor } from "~/lib/avatar-colors";
|
||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
||||
|
||||
export interface BracketMatch {
|
||||
id: string;
|
||||
|
|
@ -29,22 +28,12 @@ export interface BracketOwnership {
|
|||
|
||||
const COLUMN_WIDTH = 152;
|
||||
const CONNECTOR_WIDTH = 24;
|
||||
const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; // viewport width for 2 columns
|
||||
const LABEL_HEIGHT = 32;
|
||||
const CARD_GAP = 14; // vertical gap between cards (split top/bottom)
|
||||
const DESIRED_CARD_HEIGHT = 112; // target card height — tall enough to show owner info
|
||||
const MAX_CARD_HEIGHT = 140; // cap for sparse later rounds
|
||||
export const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH;
|
||||
export const LABEL_HEIGHT = 32;
|
||||
export const CARD_GAP = 14;
|
||||
export const DESIRED_CARD_HEIGHT = 112;
|
||||
export const MAX_CARD_HEIGHT = 140;
|
||||
|
||||
// Avatar color palette (matches TeamOwnerBadge)
|
||||
const AVATAR_COLORS = ["#adf661", "#2ce1c1", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6"];
|
||||
function hashName(name: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff;
|
||||
return h;
|
||||
}
|
||||
function avatarColor(name: string) {
|
||||
return AVATAR_COLORS[hashName(name) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
function formatScore(score: string | null): string | null {
|
||||
if (!score) return null;
|
||||
|
|
@ -191,7 +180,7 @@ export function BracketMatchSlot({
|
|||
|
||||
// Corona glow: gradient for complete matches, electric for user's picks, subtle white for pending
|
||||
const coronaStyle: React.CSSProperties = match.isComplete
|
||||
? { background: "linear-gradient(to bottom, #adf661, #2ce1c1)" }
|
||||
? { background: BRACKT_GRADIENT }
|
||||
: matchHasOwned
|
||||
? { background: "rgba(44, 225, 193, 0.4)" }
|
||||
: { background: "rgba(255, 255, 255, 0.07)" };
|
||||
|
|
@ -462,205 +451,3 @@ export function BracketTreeView({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Paginated mobile tree ────────────────────────────────────────────────────
|
||||
|
||||
interface BracketTreePaginatedProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
}
|
||||
|
||||
interface AnimState {
|
||||
fromPage: number;
|
||||
toPage: number;
|
||||
dir: "left" | "right";
|
||||
phase: "sliding" | "settling";
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
thirdPlaceRound,
|
||||
}: BracketTreePaginatedProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
),
|
||||
);
|
||||
const [page, setPage] = useState(defaultPage);
|
||||
const [anim, setAnim] = useState<AnimState | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition
|
||||
useEffect(() => {
|
||||
if (!anim || anim.phase !== "sliding" || !stripRef.current) return;
|
||||
const el = stripRef.current;
|
||||
const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 200ms ease";
|
||||
el.style.transform = `translateX(${finalX}px)`;
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [anim]);
|
||||
|
||||
// Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions
|
||||
useEffect(() => {
|
||||
if (!anim || anim.phase !== "settling") return;
|
||||
const timer = setTimeout(() => setAnim(null), 520);
|
||||
return () => clearTimeout(timer);
|
||||
}, [anim]);
|
||||
|
||||
const navigate = (newPage: number) => {
|
||||
if (anim || newPage < 0 || newPage > mainRounds.length - 2) return;
|
||||
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
|
||||
};
|
||||
|
||||
const handleTransitionEnd = () => {
|
||||
if (!anim || anim.phase !== "sliding") return;
|
||||
if (stripRef.current) {
|
||||
stripRef.current.style.transition = "none";
|
||||
stripRef.current.style.transform = "translateX(0)";
|
||||
}
|
||||
setPage(anim.toPage);
|
||||
setAnim(prev => prev ? { ...prev, phase: "settling" } : null);
|
||||
};
|
||||
|
||||
const targetPage = anim ? anim.toPage : page;
|
||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||
|
||||
const calcHeight = (p: number) => {
|
||||
const rs = mainRounds.slice(p, p + 2);
|
||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
};
|
||||
|
||||
const pageHeight = calcHeight(page);
|
||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
|
||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||
|
||||
// Sliding: show from/to slots side-by-side at their respective heights, no transitions
|
||||
// Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly
|
||||
// Null: show current page
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
}
|
||||
|
||||
// During settling, minHeight transitions from animFromHeight to animToHeight in sync with cards
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page - 1)}
|
||||
disabled={page === 0 || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Previous rounds"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
|
||||
{label}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
|
||||
<div
|
||||
ref={anim?.phase === "sliding" ? stripRef : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
|
||||
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
|
||||
}}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={leftHeight}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={rightHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{thirdPlaceMatch && (
|
||||
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
|
||||
<div
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
|
||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
3rd Place
|
||||
</div>
|
||||
<BracketMatchSlot
|
||||
match={thirdPlaceMatch}
|
||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
|
|
|
|||
|
|
@ -10,13 +10,9 @@ import {
|
|||
import { Trophy, Star } from "lucide-react";
|
||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import {
|
||||
BracketTreeView,
|
||||
BracketTreePaginated,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import { RankingsRow } from "./RankingsRow";
|
||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
|
@ -437,38 +433,17 @@ export function PlayoffBracket({
|
|||
<CardContent className="px-3 sm:px-6">
|
||||
<div className="space-y-3">
|
||||
{bracketWinner && (
|
||||
<div className={rankingRowCls(1)}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<TeamAvatar
|
||||
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
|
||||
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
|
||||
{bracketWinner.name}
|
||||
{winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||
</p>
|
||||
{winnerOwnership?.ownerName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||
<span className="text-2xl font-bold leading-none">1</span>
|
||||
</div>
|
||||
{pointsMap.size > 0 && (
|
||||
<>
|
||||
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||||
<span className="text-2xl font-bold leading-none text-electric">{winnerPts ?? 0}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RankingsRow
|
||||
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
|
||||
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
|
||||
participantName={bracketWinner.name}
|
||||
ownerName={winnerOwnership?.ownerName}
|
||||
isOwned={winnerIsOwned}
|
||||
rankLabel={1}
|
||||
points={winnerPts}
|
||||
showPoints={pointsMap.size > 0}
|
||||
rowClass={rankingRowCls(1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
|
||||
|
|
@ -476,38 +451,18 @@ export function PlayoffBracket({
|
|||
const pts = pointsMap.get(entry.participant.id);
|
||||
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
|
||||
return (
|
||||
<div key={entry.participant.id} className={rankingRowCls(numRank)}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<TeamAvatar
|
||||
teamId={entry.ownership?.teamId ?? entry.participant.id}
|
||||
teamName={entry.ownership?.teamName ?? entry.participant.name}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||
{entry.participant.name}
|
||||
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||
</p>
|
||||
{entry.ownership?.ownerName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.ownership.ownerName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||
<span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
|
||||
</div>
|
||||
{pointsMap.size > 0 && (
|
||||
<>
|
||||
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||||
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RankingsRow
|
||||
key={entry.participant.id}
|
||||
teamId={entry.ownership?.teamId ?? entry.participant.id}
|
||||
teamName={entry.ownership?.teamName ?? entry.participant.name}
|
||||
participantName={entry.participant.name}
|
||||
ownerName={entry.ownership?.ownerName}
|
||||
isOwned={isOwned}
|
||||
rankLabel={entry.rankLabel}
|
||||
points={pts}
|
||||
showPoints={pointsMap.size > 0}
|
||||
rowClass={rankingRowCls(numRank)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
|
|
@ -516,31 +471,17 @@ export function PlayoffBracket({
|
|||
const ownership = ownershipMap.get(p.id);
|
||||
const pts = pointsMap.get(p.id);
|
||||
return (
|
||||
<div key={p.id} className={rankingRowCls(undefined)}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<TeamAvatar
|
||||
teamId={ownership?.teamId ?? p.id}
|
||||
teamName={ownership?.teamName ?? p.name}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||
{p.name}
|
||||
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||
</p>
|
||||
{ownership?.ownerName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{ownership.ownerName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{pointsMap.size > 0 && (
|
||||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||||
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RankingsRow
|
||||
key={p.id}
|
||||
teamId={ownership?.teamId ?? p.id}
|
||||
teamName={ownership?.teamName ?? p.name}
|
||||
participantName={p.name}
|
||||
ownerName={ownership?.ownerName}
|
||||
isOwned={isOwned}
|
||||
points={pts}
|
||||
showPoints={pointsMap.size > 0}
|
||||
rowClass={rankingRowCls(undefined)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
67
app/components/scoring/RankingsRow.stories.tsx
Normal file
67
app/components/scoring/RankingsRow.stories.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RankingsRow } from "./RankingsRow";
|
||||
|
||||
const meta: Meta<typeof RankingsRow> = {
|
||||
title: "Scoring/RankingsRow",
|
||||
component: RankingsRow,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RankingsRow>;
|
||||
|
||||
const baseArgs = {
|
||||
teamId: "team-1",
|
||||
teamName: "Thunder Hawks",
|
||||
participantName: "LeBron James",
|
||||
ownerName: "Chris M." as string | null,
|
||||
isOwned: false,
|
||||
rowClass: "flex items-center justify-between p-3 rounded-lg bg-card border border-border",
|
||||
};
|
||||
|
||||
export const WithRankAndPoints: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
rankLabel: 1,
|
||||
points: 100,
|
||||
showPoints: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRankOnly: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
rankLabel: 3,
|
||||
showPoints: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const OwnedByUser: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
participantName: "Jayson Tatum",
|
||||
isOwned: true,
|
||||
rankLabel: 2,
|
||||
points: 70,
|
||||
showPoints: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOwnerName: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
ownerName: "Sarah J.",
|
||||
rankLabel: 4,
|
||||
points: 40,
|
||||
showPoints: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutRankOrPoints: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
showPoints: false,
|
||||
},
|
||||
};
|
||||
62
app/components/scoring/RankingsRow.tsx
Normal file
62
app/components/scoring/RankingsRow.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Star } from "lucide-react";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
|
||||
interface RankingsRowProps {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
participantName: string;
|
||||
ownerName?: string | null;
|
||||
isOwned: boolean;
|
||||
rankLabel?: string | number;
|
||||
points?: number;
|
||||
showPoints: boolean;
|
||||
rowClass: string;
|
||||
}
|
||||
|
||||
export function RankingsRow({
|
||||
teamId,
|
||||
teamName,
|
||||
participantName,
|
||||
ownerName,
|
||||
isOwned,
|
||||
rankLabel,
|
||||
points,
|
||||
showPoints,
|
||||
rowClass,
|
||||
}: RankingsRowProps) {
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<TeamAvatar teamId={teamId} teamName={teamName} />
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||
{participantName}
|
||||
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||
</p>
|
||||
{ownerName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{ownerName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(rankLabel !== undefined || showPoints) && (
|
||||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||
{rankLabel !== undefined && (
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||
<span className="text-2xl font-bold leading-none">{rankLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{showPoints && rankLabel !== undefined && (
|
||||
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||
)}
|
||||
{showPoints && (
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||||
<span className="text-2xl font-bold leading-none text-electric">{points ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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 === ' ') {
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#adf661" />
|
||||
<stop offset="100%" stopColor="#2ce1c1" />
|
||||
<stop offset="0%" stopColor={BRACKT_GRADIENT_START} />
|
||||
<stop offset="100%" stopColor={BRACKT_GRADIENT_END} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
54
app/components/ui/team-owner-badge.stories.tsx
Normal file
54
app/components/ui/team-owner-badge.stories.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TeamOwnerBadge } from "./team-owner-badge";
|
||||
|
||||
const meta: Meta<typeof TeamOwnerBadge> = {
|
||||
title: "UI/TeamOwnerBadge",
|
||||
component: TeamOwnerBadge,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TeamOwnerBadge>;
|
||||
|
||||
export const LeftAligned: Story = {
|
||||
args: {
|
||||
teamName: "Thunder Hawks",
|
||||
ownerName: "Chris M.",
|
||||
},
|
||||
};
|
||||
|
||||
export const RightAligned: Story = {
|
||||
args: {
|
||||
teamName: "Shadow Hawks",
|
||||
ownerName: "Sarah J.",
|
||||
align: "right",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutOwner: Story = {
|
||||
args: {
|
||||
teamName: "Iron Eagles",
|
||||
},
|
||||
};
|
||||
|
||||
export const LongNames: Story = {
|
||||
name: "Long Names (truncation)",
|
||||
args: {
|
||||
teamName: "The Incredibly Long Named Fantasy Team",
|
||||
ownerName: "Alexander Hamilton-Washington",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
name: "All Variants — Side by Side",
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 max-w-xs">
|
||||
<TeamOwnerBadge teamName="Thunder Hawks" ownerName="Chris M." />
|
||||
<TeamOwnerBadge teamName="Shadow Hawks" ownerName="Sarah J." align="right" />
|
||||
<TeamOwnerBadge teamName="Iron Eagles" />
|
||||
<TeamOwnerBadge teamName="The Incredibly Long Named Fantasy Team" ownerName="Alexander Hamilton-Washington" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
|
@ -1,19 +1,4 @@
|
|||
const AVATAR_COLORS = [
|
||||
"#adf661",
|
||||
"#2ce1c1",
|
||||
"#8b5cf6",
|
||||
"#f59e0b",
|
||||
"#ef4444",
|
||||
"#3b82f6",
|
||||
];
|
||||
|
||||
function hashName(name: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) & 0xffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
import { avatarColor } from "~/lib/avatar-colors";
|
||||
|
||||
interface TeamOwnerBadgeProps {
|
||||
teamName: string;
|
||||
|
|
@ -33,7 +18,7 @@ export function TeamOwnerBadge({
|
|||
.slice(0, 2)
|
||||
.map((w) => w[0].toUpperCase())
|
||||
.join("") || "?";
|
||||
const color = AVATAR_COLORS[hashName(teamName) % AVATAR_COLORS.length];
|
||||
const color = avatarColor(teamName);
|
||||
|
||||
const avatar = (
|
||||
<div
|
||||
|
|
|
|||
251
app/hooks/useDraftAuthRecovery.ts
Normal file
251
app/hooks/useDraftAuthRecovery.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { getDraftPicksForSeason } from "~/models/draft-pick";
|
||||
import type { findSeasonWithTeamsAndLeague } from "~/models/season";
|
||||
import type { getSeasonTimers } from "~/models/draft-timer";
|
||||
import type { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
|
||||
import type { AutodraftStatusEntry, QueueItem } from "~/hooks/useDraftRoomState";
|
||||
|
||||
type DraftPicks = Awaited<ReturnType<typeof getDraftPicksForSeason>>;
|
||||
type Season = NonNullable<Awaited<ReturnType<typeof findSeasonWithTeamsAndLeague>>>;
|
||||
type Timers = Awaited<ReturnType<typeof getSeasonTimers>>;
|
||||
type AutodraftSettings = Awaited<ReturnType<typeof getSeasonAutodraftSettings>>;
|
||||
|
||||
interface UseDraftAuthRecoveryParams {
|
||||
reconnectCount: number;
|
||||
revalidate: () => void;
|
||||
revalidatorState: "idle" | "loading" | "submitting";
|
||||
clerkUserId: string | null | undefined;
|
||||
currentUserId: string | null | undefined;
|
||||
getToken: (opts?: { skipCache?: boolean }) => Promise<string | null>;
|
||||
userAutodraftSettings: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean } | null;
|
||||
// Loader data for revalidation sync
|
||||
draftPicks: DraftPicks;
|
||||
season: Season;
|
||||
userQueue: QueueItem[];
|
||||
timers: Timers;
|
||||
autodraftSettings: AutodraftSettings;
|
||||
// State setters to sync after revalidation
|
||||
setPicks: (picks: DraftPicks) => void;
|
||||
setCurrentPick: (pick: number) => void;
|
||||
setIsPaused: (paused: boolean) => void;
|
||||
setIsDraftComplete: (complete: boolean) => void;
|
||||
setQueue: (queue: QueueItem[]) => void;
|
||||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
|
||||
}
|
||||
|
||||
const MAX_AUTH_RECOVERY_ATTEMPTS = 3;
|
||||
|
||||
export function useDraftAuthRecovery({
|
||||
reconnectCount,
|
||||
revalidate,
|
||||
revalidatorState,
|
||||
clerkUserId,
|
||||
currentUserId,
|
||||
getToken,
|
||||
userAutodraftSettings,
|
||||
draftPicks,
|
||||
season,
|
||||
userQueue,
|
||||
timers,
|
||||
autodraftSettings,
|
||||
setPicks,
|
||||
setCurrentPick,
|
||||
setIsPaused,
|
||||
setIsDraftComplete,
|
||||
setQueue,
|
||||
setTeamTimers,
|
||||
setAutodraftStatus,
|
||||
}: UseDraftAuthRecoveryParams) {
|
||||
const [authDegraded, setAuthDegraded] = useState(false);
|
||||
|
||||
const [userAutodraft, setUserAutodraft] = useState({
|
||||
isEnabled: userAutodraftSettings?.isEnabled || false,
|
||||
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
|
||||
queueOnly: userAutodraftSettings?.queueOnly || false,
|
||||
});
|
||||
const userAutodraftRef = useRef(userAutodraft);
|
||||
useEffect(() => {
|
||||
userAutodraftRef.current = userAutodraft;
|
||||
}, [userAutodraft]);
|
||||
|
||||
// Re-fetch loader data after each reconnect so stale picks/timers are refreshed.
|
||||
const revalidationRetryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (reconnectCount > 0) {
|
||||
revalidate();
|
||||
if (revalidationRetryRef.current) {
|
||||
clearTimeout(revalidationRetryRef.current);
|
||||
}
|
||||
revalidationRetryRef.current = setTimeout(() => {
|
||||
revalidate();
|
||||
revalidationRetryRef.current = null;
|
||||
}, 3000);
|
||||
}
|
||||
return () => {
|
||||
if (revalidationRetryRef.current) {
|
||||
clearTimeout(revalidationRetryRef.current);
|
||||
}
|
||||
};
|
||||
}, [reconnectCount, revalidate]);
|
||||
|
||||
// Guard against Clerk JWT expiry race.
|
||||
const authRecoveryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const authRecoveryAttemptsRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (clerkUserId && !currentUserId) {
|
||||
if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) {
|
||||
setAuthDegraded(true);
|
||||
return;
|
||||
}
|
||||
clearTimeout(authRecoveryTimerRef.current);
|
||||
authRecoveryTimerRef.current = setTimeout(() => {
|
||||
authRecoveryAttemptsRef.current += 1;
|
||||
revalidate();
|
||||
}, 100);
|
||||
} else if (currentUserId) {
|
||||
authRecoveryAttemptsRef.current = 0;
|
||||
}
|
||||
return () => clearTimeout(authRecoveryTimerRef.current);
|
||||
}, [clerkUserId, currentUserId, revalidate]);
|
||||
|
||||
// Proactively refresh the Clerk JWT when the tab becomes visible again.
|
||||
useEffect(() => {
|
||||
if (authDegraded) return;
|
||||
|
||||
let aborted = false;
|
||||
let inFlight = false;
|
||||
|
||||
const handleVisibilityChange = async () => {
|
||||
if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const token = await getToken({ skipCache: true });
|
||||
if (aborted) return;
|
||||
if (token !== null) {
|
||||
if (!currentUserId) {
|
||||
revalidate();
|
||||
}
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
if (aborted) return;
|
||||
const retryToken = await getToken({ skipCache: true });
|
||||
if (aborted) return;
|
||||
if (retryToken !== null) {
|
||||
revalidate();
|
||||
} else {
|
||||
setAuthDegraded(true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
if (aborted) return;
|
||||
const retryToken = await getToken({ skipCache: true });
|
||||
if (aborted) return;
|
||||
if (retryToken !== null) {
|
||||
revalidate();
|
||||
} else {
|
||||
setAuthDegraded(true);
|
||||
}
|
||||
} catch {
|
||||
if (!aborted) setAuthDegraded(true);
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
aborted = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]);
|
||||
|
||||
// Track revalidation lifecycle to sync local state from fresh loader data.
|
||||
const revalidatorStateRef = useRef(revalidatorState);
|
||||
const isRevalidatingRef = useRef(false);
|
||||
const pendingPicksDuringRevalidationRef = useRef<DraftPicks>([]);
|
||||
const draftPicksAtRevalidationStartRef = useRef(draftPicks);
|
||||
const userQueueAtRevalidationStartRef = useRef(userQueue);
|
||||
const pendingQueueMutationsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = revalidatorStateRef.current;
|
||||
revalidatorStateRef.current = revalidatorState;
|
||||
|
||||
if (prev !== "loading" && revalidatorState === "loading") {
|
||||
isRevalidatingRef.current = true;
|
||||
pendingPicksDuringRevalidationRef.current = [];
|
||||
draftPicksAtRevalidationStartRef.current = draftPicks;
|
||||
userQueueAtRevalidationStartRef.current = userQueue;
|
||||
} else if (prev === "loading" && revalidatorState === "idle") {
|
||||
isRevalidatingRef.current = false;
|
||||
|
||||
if (draftPicks === draftPicksAtRevalidationStartRef.current) {
|
||||
pendingPicksDuringRevalidationRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPickIds = new Set(draftPicks.map((p) => p.id));
|
||||
const missedPicks = pendingPicksDuringRevalidationRef.current.filter(
|
||||
(p) => !dbPickIds.has(p.id)
|
||||
);
|
||||
pendingPicksDuringRevalidationRef.current = [];
|
||||
|
||||
setPicks([...draftPicks, ...missedPicks]);
|
||||
setCurrentPick(season.currentPickNumber || 1);
|
||||
setIsPaused(season.draftPaused || false);
|
||||
setIsDraftComplete(
|
||||
season.status === "active" || season.status === "completed"
|
||||
);
|
||||
|
||||
if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) {
|
||||
setQueue(userQueue);
|
||||
}
|
||||
|
||||
if (timers.length > 0) {
|
||||
setTeamTimers((currentTimers) => {
|
||||
const updated = { ...currentTimers };
|
||||
timers.forEach((timer) => {
|
||||
updated[timer.teamId] = timer.timeRemaining;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
setAutodraftStatus(() => {
|
||||
const status: Record<string, AutodraftStatusEntry> = {};
|
||||
autodraftSettings.forEach((setting) => {
|
||||
status[setting.teamId] = {
|
||||
isEnabled: setting.isEnabled,
|
||||
mode: setting.mode,
|
||||
queueOnly: setting.queueOnly,
|
||||
};
|
||||
});
|
||||
return status;
|
||||
});
|
||||
}
|
||||
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId,
|
||||
setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus]);
|
||||
|
||||
const authFetch = useCallback(async (url: string, init?: RequestInit): Promise<Response | null> => {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) {
|
||||
setAuthDegraded(true);
|
||||
return null;
|
||||
}
|
||||
return response;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
authDegraded,
|
||||
authFetch,
|
||||
userAutodraft,
|
||||
setUserAutodraft,
|
||||
userAutodraftRef,
|
||||
isRevalidatingRef,
|
||||
pendingPicksDuringRevalidationRef,
|
||||
pendingQueueMutationsRef,
|
||||
};
|
||||
}
|
||||
164
app/hooks/useDraftRoomState.ts
Normal file
164
app/hooks/useDraftRoomState.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useState } from "react";
|
||||
import { useMediaQuery } from "~/hooks/useMediaQuery";
|
||||
import type * as schema from "~/database/schema";
|
||||
import type { getDraftPicksForSeason } from "~/models/draft-pick";
|
||||
import type { findSeasonWithTeamsAndLeague } from "~/models/season";
|
||||
import type { findDraftSlotsBySeasonId } from "~/models/draft-slot";
|
||||
import type { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
|
||||
import type { getSeasonTimers } from "~/models/draft-timer";
|
||||
|
||||
export type QueueItem = typeof schema.draftQueue.$inferSelect;
|
||||
|
||||
export type AutodraftStatusEntry = {
|
||||
isEnabled: boolean;
|
||||
mode: "next_pick" | "while_on";
|
||||
queueOnly: boolean;
|
||||
};
|
||||
|
||||
type DraftPicks = Awaited<ReturnType<typeof getDraftPicksForSeason>>;
|
||||
type Season = NonNullable<Awaited<ReturnType<typeof findSeasonWithTeamsAndLeague>>>;
|
||||
type DraftSlots = Awaited<ReturnType<typeof findDraftSlotsBySeasonId>>;
|
||||
type AutodraftSettings = Awaited<ReturnType<typeof getSeasonAutodraftSettings>>;
|
||||
type Timers = Awaited<ReturnType<typeof getSeasonTimers>>;
|
||||
|
||||
interface UseDraftRoomStateParams {
|
||||
draftPicks: DraftPicks;
|
||||
season: Season;
|
||||
userTeam: { id: string } | undefined;
|
||||
isCommissioner: boolean;
|
||||
autodraftSettings: AutodraftSettings;
|
||||
timers: Timers;
|
||||
draftSlots: DraftSlots;
|
||||
userQueue: QueueItem[];
|
||||
}
|
||||
|
||||
export function useDraftRoomState({
|
||||
draftPicks,
|
||||
season,
|
||||
userTeam,
|
||||
isCommissioner,
|
||||
autodraftSettings,
|
||||
timers,
|
||||
draftSlots,
|
||||
userQueue,
|
||||
}: UseDraftRoomStateParams) {
|
||||
const [picks, setPicks] = useState(draftPicks);
|
||||
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hideDrafted, setHideDrafted] = useState(true);
|
||||
const [hideIneligible, setHideIneligible] = useState(true);
|
||||
const [hideCompletedSports, setHideCompletedSports] = useState(false);
|
||||
const [sportFilters, setSportFilters] = useState<string[]>([]);
|
||||
const [queue, setQueue] = useState<QueueItem[]>(userQueue);
|
||||
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
||||
const [isDraftComplete, setIsDraftComplete] = useState(
|
||||
season.status === "active" || season.status === "completed"
|
||||
);
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const stored = localStorage.getItem("draftSidebarCollapsed");
|
||||
return stored ? JSON.parse(stored) : false;
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("participants");
|
||||
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">(
|
||||
!userTeam && isCommissioner ? "board" : "available"
|
||||
);
|
||||
const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters");
|
||||
const [boardSubTab, setBoardSubTab] = useState<"board" | "pick-list">("board");
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
|
||||
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, AutodraftStatusEntry>>(() => {
|
||||
const status: Record<string, AutodraftStatusEntry> = {};
|
||||
autodraftSettings.forEach((setting) => {
|
||||
status[setting.teamId] = {
|
||||
isEnabled: setting.isEnabled,
|
||||
mode: setting.mode,
|
||||
queueOnly: setting.queueOnly,
|
||||
};
|
||||
});
|
||||
return status;
|
||||
});
|
||||
|
||||
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
|
||||
|
||||
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
|
||||
const timersMap: Record<string, number> = {};
|
||||
const initialTime = season.draftInitialTime || 120;
|
||||
|
||||
if (timers.length > 0) {
|
||||
timers.forEach((timer) => {
|
||||
timersMap[timer.teamId] = timer.timeRemaining;
|
||||
});
|
||||
} else {
|
||||
draftSlots.forEach((slot) => {
|
||||
timersMap[slot.team.id] = initialTime;
|
||||
});
|
||||
}
|
||||
|
||||
return timersMap;
|
||||
});
|
||||
|
||||
const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false);
|
||||
const [selectedPickSlot, setSelectedPickSlot] = useState<{
|
||||
pickNumber: number;
|
||||
teamId: string;
|
||||
} | null>(null);
|
||||
|
||||
const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false);
|
||||
const [replacePickSlot, setReplacePickSlot] = useState<{
|
||||
pickNumber: number;
|
||||
teamId: string;
|
||||
oldParticipantId: string;
|
||||
} | null>(null);
|
||||
|
||||
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
|
||||
const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null);
|
||||
const [isRollingBack, setIsRollingBack] = useState(false);
|
||||
|
||||
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
|
||||
const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(null);
|
||||
const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false);
|
||||
const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState<string | null>(null);
|
||||
const [timeBankAmount, setTimeBankAmount] = useState("1");
|
||||
const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes");
|
||||
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
|
||||
const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false);
|
||||
|
||||
return {
|
||||
picks, setPicks,
|
||||
currentPick, setCurrentPick,
|
||||
searchQuery, setSearchQuery,
|
||||
hideDrafted, setHideDrafted,
|
||||
hideIneligible, setHideIneligible,
|
||||
hideCompletedSports, setHideCompletedSports,
|
||||
sportFilters, setSportFilters,
|
||||
queue, setQueue,
|
||||
isPaused, setIsPaused,
|
||||
isDraftComplete, setIsDraftComplete,
|
||||
sidebarCollapsed, setSidebarCollapsed,
|
||||
activeTab, setActiveTab,
|
||||
mobileTab, setMobileTab,
|
||||
teamsSubTab, setTeamsSubTab,
|
||||
boardSubTab, setBoardSubTab,
|
||||
isMobile,
|
||||
autodraftStatus, setAutodraftStatus,
|
||||
connectedTeams, setConnectedTeams,
|
||||
teamTimers, setTeamTimers,
|
||||
forcePickDialogOpen, setForcePickDialogOpen,
|
||||
selectedPickSlot, setSelectedPickSlot,
|
||||
replacePickDialogOpen, setReplacePickDialogOpen,
|
||||
replacePickSlot, setReplacePickSlot,
|
||||
rollbackConfirmOpen, setRollbackConfirmOpen,
|
||||
rollbackPickNumber, setRollbackPickNumber,
|
||||
isRollingBack, setIsRollingBack,
|
||||
timeBankDialogOpen, setTimeBankDialogOpen,
|
||||
timeBankTeamId, setTimeBankTeamId,
|
||||
commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen,
|
||||
commissionerAutodraftTeamId, setCommissionerAutodraftTeamId,
|
||||
timeBankAmount, setTimeBankAmount,
|
||||
timeBankUnit, setTimeBankUnit,
|
||||
timeBankDirection, setTimeBankDirection,
|
||||
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
||||
};
|
||||
}
|
||||
244
app/hooks/useDraftSocketEvents.ts
Normal file
244
app/hooks/useDraftSocketEvents.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { useEffect, type MutableRefObject, type RefObject } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getTeamForPick } from "~/lib/draft-order";
|
||||
import { getAutodraftLabel } from "~/components/AutodraftSettings";
|
||||
import type { getDraftPicksForSeason } from "~/models/draft-pick";
|
||||
import type { findDraftSlotsBySeasonId } from "~/models/draft-slot";
|
||||
import type { QueueItem, AutodraftStatusEntry } from "~/hooks/useDraftRoomState";
|
||||
|
||||
type DraftPicks = Awaited<ReturnType<typeof getDraftPicksForSeason>>;
|
||||
type DraftSlots = Awaited<ReturnType<typeof findDraftSlotsBySeasonId>>;
|
||||
type DraftPick = DraftPicks[number];
|
||||
|
||||
interface UseDraftSocketEventsParams {
|
||||
on: (event: string, handler: (data: unknown) => void) => void;
|
||||
off: (event: string, handler: (data: unknown) => void) => void;
|
||||
socketVersion: number;
|
||||
// Loader-derived, stable for lifetime of page load
|
||||
userTeam: { id: string } | undefined;
|
||||
draftSlots: DraftSlots;
|
||||
leagueName: string;
|
||||
// Refs from useDraftAuthRecovery
|
||||
isRevalidatingRef: RefObject<boolean>;
|
||||
pendingPicksDuringRevalidationRef: MutableRefObject<DraftPicks>;
|
||||
pendingQueueMutationsRef: MutableRefObject<number>;
|
||||
userAutodraftRef: MutableRefObject<{ isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
|
||||
// Notification refs from the component
|
||||
sendNotificationRef: MutableRefObject<(title: string, body: string) => void>;
|
||||
notificationsModeRef: MutableRefObject<string>;
|
||||
// State setters
|
||||
setPicks: (fn: (prev: DraftPicks) => DraftPicks) => void;
|
||||
setCurrentPick: (pick: number) => void;
|
||||
setIsPaused: (paused: boolean) => void;
|
||||
setIsDraftComplete: (complete: boolean) => void;
|
||||
setAutodraftStatus: (fn: (prev: Record<string, AutodraftStatusEntry>) => Record<string, AutodraftStatusEntry>) => void;
|
||||
setUserAutodraft: (value: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => void;
|
||||
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
|
||||
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
|
||||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||
}
|
||||
|
||||
export function useDraftSocketEvents({
|
||||
on,
|
||||
off,
|
||||
socketVersion,
|
||||
userTeam,
|
||||
draftSlots,
|
||||
leagueName,
|
||||
isRevalidatingRef,
|
||||
pendingPicksDuringRevalidationRef,
|
||||
pendingQueueMutationsRef,
|
||||
userAutodraftRef,
|
||||
sendNotificationRef,
|
||||
notificationsModeRef,
|
||||
setPicks,
|
||||
setCurrentPick,
|
||||
setIsPaused,
|
||||
setIsDraftComplete,
|
||||
setAutodraftStatus,
|
||||
setUserAutodraft,
|
||||
setConnectedTeams,
|
||||
setQueue,
|
||||
setTeamTimers,
|
||||
}: UseDraftSocketEventsParams) {
|
||||
useEffect(() => {
|
||||
type PickMadePayload = {
|
||||
pick: DraftPick & { team?: { id?: string; name?: string }; participant?: { name?: string } };
|
||||
nextPickNumber: number;
|
||||
isDraftComplete?: boolean;
|
||||
};
|
||||
const handlePickMade = (data: PickMadePayload) => {
|
||||
if (isRevalidatingRef.current) {
|
||||
pendingPicksDuringRevalidationRef.current.push(data.pick);
|
||||
} else {
|
||||
setPicks((prev) => [...prev, data.pick]);
|
||||
setCurrentPick(data.nextPickNumber);
|
||||
}
|
||||
|
||||
if (data.isDraftComplete) return;
|
||||
|
||||
const notifTitle = `${leagueName} Draft`;
|
||||
const nextSlot = getTeamForPick(data.nextPickNumber, draftSlots);
|
||||
const isNowMyTurn = !!(userTeam && nextSlot && nextSlot.team.id === userTeam.id);
|
||||
|
||||
if (isNowMyTurn) {
|
||||
sendNotificationRef.current(notifTitle, `It's your turn to pick!`);
|
||||
} else if (
|
||||
notificationsModeRef.current === "all_picks" &&
|
||||
data.pick?.team?.id !== userTeam?.id
|
||||
) {
|
||||
const pickerName = data.pick?.team?.name || "A team";
|
||||
const participantName = data.pick?.participant?.name || "a participant";
|
||||
sendNotificationRef.current(notifTitle, `${pickerName} picked ${participantName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimerUpdate = (data: { teamId: string; timeRemaining: number; currentPickNumber: number | null }) => {
|
||||
setTeamTimers((prev) => {
|
||||
if (prev[data.teamId] === data.timeRemaining) return prev;
|
||||
return { ...prev, [data.teamId]: data.timeRemaining };
|
||||
});
|
||||
if (data.currentPickNumber !== null) {
|
||||
setCurrentPick(data.currentPickNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraftPaused = () => setIsPaused(true);
|
||||
const handleDraftResumed = () => setIsPaused(false);
|
||||
const handleDraftCompleted = () => setIsDraftComplete(true);
|
||||
|
||||
const handleAutodraftUpdated = (data: {
|
||||
teamId: string;
|
||||
isEnabled: boolean;
|
||||
mode: "next_pick" | "while_on";
|
||||
queueOnly: boolean;
|
||||
source?: "commissioner" | "user";
|
||||
}) => {
|
||||
setAutodraftStatus((prev) => ({
|
||||
...prev,
|
||||
[data.teamId]: { isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly },
|
||||
}));
|
||||
|
||||
if (userTeam && data.teamId === userTeam.id) {
|
||||
if (data.source === "commissioner") {
|
||||
if (!data.isEnabled) {
|
||||
toast.info("Commissioner turned off your autodraft");
|
||||
} else {
|
||||
toast.info(`Commissioner changed your autodraft to "${getAutodraftLabel(data.isEnabled, data.mode, data.queueOnly)}"`);
|
||||
}
|
||||
} else {
|
||||
const prev = userAutodraftRef.current;
|
||||
if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") {
|
||||
toast.info("Autodraft disabled — your queue is empty");
|
||||
}
|
||||
}
|
||||
|
||||
setUserAutodraft({ isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTeamConnected = (data: { teamId: string }) => {
|
||||
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
|
||||
};
|
||||
|
||||
const handleTeamDisconnected = (data: { teamId: string }) => {
|
||||
setConnectedTeams((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(data.teamId);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
|
||||
setConnectedTeams(() => new Set(data.teamIds));
|
||||
};
|
||||
|
||||
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
|
||||
setQueue((prev) => prev.filter((item) => item.participantId !== data.participantId));
|
||||
};
|
||||
|
||||
const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => {
|
||||
if (data.teamId !== userTeam?.id) return;
|
||||
const removed = new Set(data.removedParticipantIds);
|
||||
setQueue((prev) => prev.filter((item) => !removed.has(item.participantId)));
|
||||
};
|
||||
|
||||
const handleQueueUpdated = (data: { queue: QueueItem[] }) => {
|
||||
if (pendingQueueMutationsRef.current > 0) return;
|
||||
setQueue(() => data.queue);
|
||||
};
|
||||
|
||||
const handlePickReplaced = (data: { pickNumber: number; pick: DraftPick }) => {
|
||||
setPicks((prev) => prev.map((p) => (p.pickNumber === data.pickNumber ? data.pick : p)));
|
||||
};
|
||||
|
||||
const handleDraftRolledBack = (data: { pickNumber: number }) => {
|
||||
setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber));
|
||||
setCurrentPick(data.pickNumber);
|
||||
setIsDraftComplete(false);
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
const handleDraftStateSync = (data: {
|
||||
picks: DraftPicks;
|
||||
currentPickNumber: number;
|
||||
isPaused: boolean;
|
||||
status: string;
|
||||
timers?: Array<{ teamId: string; timeRemaining: number }>;
|
||||
queue?: QueueItem[];
|
||||
}) => {
|
||||
if (!isRevalidatingRef.current) {
|
||||
setPicks(() => data.picks);
|
||||
setCurrentPick(data.currentPickNumber);
|
||||
setIsPaused(data.isPaused);
|
||||
setIsDraftComplete(data.status === "active" || data.status === "completed");
|
||||
if (data.timers) {
|
||||
setTeamTimers((prev) => {
|
||||
const updated = { ...prev };
|
||||
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
if (data.queue) {
|
||||
const q = data.queue;
|
||||
setQueue(() => q);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
on("pick-made", handlePickMade as (data: unknown) => void);
|
||||
on("timer-update", handleTimerUpdate as (data: unknown) => void);
|
||||
on("draft-paused", handleDraftPaused as (data: unknown) => void);
|
||||
on("draft-resumed", handleDraftResumed as (data: unknown) => void);
|
||||
on("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
||||
on("autodraft-updated", handleAutodraftUpdated as (data: unknown) => void);
|
||||
on("team-connected", handleTeamConnected as (data: unknown) => void);
|
||||
on("team-disconnected", handleTeamDisconnected as (data: unknown) => void);
|
||||
on("connected-teams-list", handleConnectedTeamsList as (data: unknown) => void);
|
||||
on("participant-removed-from-queues", handleParticipantRemovedFromQueues as (data: unknown) => void);
|
||||
on("queue-eligibility-pruned", handleQueueEligibilityPruned as (data: unknown) => void);
|
||||
on("queue-updated", handleQueueUpdated as (data: unknown) => void);
|
||||
on("pick-replaced", handlePickReplaced as (data: unknown) => void);
|
||||
on("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
|
||||
on("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
||||
|
||||
return () => {
|
||||
off("pick-made", handlePickMade as (data: unknown) => void);
|
||||
off("timer-update", handleTimerUpdate as (data: unknown) => void);
|
||||
off("draft-paused", handleDraftPaused as (data: unknown) => void);
|
||||
off("draft-resumed", handleDraftResumed as (data: unknown) => void);
|
||||
off("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
||||
off("autodraft-updated", handleAutodraftUpdated as (data: unknown) => void);
|
||||
off("team-connected", handleTeamConnected as (data: unknown) => void);
|
||||
off("team-disconnected", handleTeamDisconnected as (data: unknown) => void);
|
||||
off("connected-teams-list", handleConnectedTeamsList as (data: unknown) => void);
|
||||
off("participant-removed-from-queues", handleParticipantRemovedFromQueues as (data: unknown) => void);
|
||||
off("queue-eligibility-pruned", handleQueueEligibilityPruned as (data: unknown) => void);
|
||||
off("queue-updated", handleQueueUpdated as (data: unknown) => void);
|
||||
off("pick-replaced", handlePickReplaced as (data: unknown) => void);
|
||||
off("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
|
||||
off("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [on, off, socketVersion]);
|
||||
}
|
||||
49
app/hooks/useRoundTransition.ts
Normal file
49
app/hooks/useRoundTransition.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { SLOT_WIDTH } from "~/components/scoring/BracketTreeView";
|
||||
|
||||
interface AnimState {
|
||||
fromPage: number;
|
||||
toPage: number;
|
||||
dir: "left" | "right";
|
||||
phase: "sliding" | "settling";
|
||||
}
|
||||
|
||||
export function useRoundTransition(maxPage: number, defaultPage: number) {
|
||||
const [page, setPage] = useState(defaultPage);
|
||||
const [anim, setAnim] = useState<AnimState | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anim || anim.phase !== "sliding" || !stripRef.current) return;
|
||||
const el = stripRef.current;
|
||||
const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 200ms ease";
|
||||
el.style.transform = `translateX(${finalX}px)`;
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [anim]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anim || anim.phase !== "settling") return;
|
||||
const timer = setTimeout(() => setAnim(null), 520);
|
||||
return () => clearTimeout(timer);
|
||||
}, [anim]);
|
||||
|
||||
const navigate = (newPage: number) => {
|
||||
if (anim || newPage < 0 || newPage > maxPage) return;
|
||||
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
|
||||
};
|
||||
|
||||
const handleTransitionEnd = () => {
|
||||
if (!anim || anim.phase !== "sliding") return;
|
||||
if (stripRef.current) {
|
||||
stripRef.current.style.transition = "none";
|
||||
stripRef.current.style.transform = "translateX(0)";
|
||||
}
|
||||
setPage(anim.toPage);
|
||||
setAnim((prev) => (prev ? { ...prev, phase: "settling" } : null));
|
||||
};
|
||||
|
||||
return { page, anim, stripRef, navigate, handleTransitionEnd };
|
||||
}
|
||||
56
app/lib/auth.ts
Normal file
56
app/lib/auth.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import type { database } from "~/database/context";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
|
||||
type Db = ReturnType<typeof database>;
|
||||
|
||||
/**
|
||||
* Verifies the request has an authenticated user who is either a commissioner
|
||||
* or a team owner in the given league/season. Throws Response on failure.
|
||||
*
|
||||
* @param unauthMessage - 401 message when not logged in
|
||||
* @param unauthorizedMessage - 403 message when logged in but not a member
|
||||
*/
|
||||
export async function requireLeagueAccess(
|
||||
args: LoaderFunctionArgs,
|
||||
{
|
||||
leagueId,
|
||||
seasonId,
|
||||
db,
|
||||
unauthMessage = "You must be logged in",
|
||||
unauthorizedMessage = "You do not have access to this league",
|
||||
}: {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
db: Db;
|
||||
unauthMessage?: string;
|
||||
unauthorizedMessage?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { userId } = await getAuth(args);
|
||||
|
||||
if (!userId) {
|
||||
throw new Response(unauthMessage, { status: 401 });
|
||||
}
|
||||
|
||||
const [commissioner, team] = await Promise.all([
|
||||
db.query.commissioners.findFirst({
|
||||
where: and(
|
||||
eq(schema.commissioners.leagueId, leagueId),
|
||||
eq(schema.commissioners.userId, userId)
|
||||
),
|
||||
}),
|
||||
db.query.teams.findFirst({
|
||||
where: and(
|
||||
eq(schema.teams.seasonId, seasonId),
|
||||
eq(schema.teams.ownerId, userId)
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!commissioner && !team) {
|
||||
throw new Response(unauthorizedMessage, { status: 403 });
|
||||
}
|
||||
}
|
||||
20
app/lib/avatar-colors.ts
Normal file
20
app/lib/avatar-colors.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export const AVATAR_COLORS = [
|
||||
"#adf661",
|
||||
"#2ce1c1",
|
||||
"#8b5cf6",
|
||||
"#f59e0b",
|
||||
"#ef4444",
|
||||
"#3b82f6",
|
||||
];
|
||||
|
||||
export function hashString(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) & 0xffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function avatarColor(input: string): string {
|
||||
return AVATAR_COLORS[hashString(input) % AVATAR_COLORS.length];
|
||||
}
|
||||
69
app/lib/corona-states.ts
Normal file
69
app/lib/corona-states.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
|
||||
|
||||
export type CoronaState =
|
||||
| { type: "eliminated"; points: 0 }
|
||||
| { type: "pending" }
|
||||
| { type: "scored"; brightness: number; points: number };
|
||||
|
||||
interface PickEntry {
|
||||
participant: { id: string; sportsSeasonId: string };
|
||||
scoringPattern: string | null;
|
||||
}
|
||||
|
||||
interface ResultEntry {
|
||||
participantId: string;
|
||||
finalPosition: number | null;
|
||||
isPartialScore: boolean | null;
|
||||
}
|
||||
|
||||
interface ScoringRules {
|
||||
pointsFor1st: number;
|
||||
pointsFor2nd: number;
|
||||
pointsFor3rd: number;
|
||||
pointsFor4th: number;
|
||||
pointsFor5th: number;
|
||||
pointsFor6th: number;
|
||||
pointsFor7th: number;
|
||||
pointsFor8th: number;
|
||||
}
|
||||
|
||||
export function computeCoronaStates(
|
||||
picks: PickEntry[],
|
||||
resultByParticipant: Map<string, ResultEntry>,
|
||||
bracketTemplateBySportsSeason: Map<string, string | null>,
|
||||
scoringRules: ScoringRules,
|
||||
maxPoints: number,
|
||||
): Record<string, CoronaState> {
|
||||
const coronaStates: Record<string, CoronaState> = {};
|
||||
|
||||
for (const pick of picks) {
|
||||
const result = resultByParticipant.get(pick.participant.id);
|
||||
|
||||
if (!result || result.finalPosition === null) {
|
||||
coronaStates[pick.participant.id] = { type: "pending" };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.finalPosition === 0 && !result.isPartialScore) {
|
||||
coronaStates[pick.participant.id] = { type: "eliminated", points: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.finalPosition > 0) {
|
||||
const isBracket = pick.scoringPattern === "playoff_bracket";
|
||||
const templateId = isBracket
|
||||
? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null)
|
||||
: null;
|
||||
const points = isBracket
|
||||
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
|
||||
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||
const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
|
||||
coronaStates[pick.participant.id] = { type: "scored", brightness, points };
|
||||
continue;
|
||||
}
|
||||
|
||||
coronaStates[pick.participant.id] = { type: "pending" };
|
||||
}
|
||||
|
||||
return coronaStates;
|
||||
}
|
||||
43
app/lib/landing-data.ts
Normal file
43
app/lib/landing-data.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Users, Shuffle, Trophy } from "lucide-react";
|
||||
|
||||
export const SPORTS = [
|
||||
"Football", "Baseball", "Hockey", "Basketball", "Soccer", "Tennis", "Golf",
|
||||
"eSports", "Formula 1", "Aussie Rules", "Lacrosse", "Darts", "College Football", "Snooker", "March Madness",
|
||||
];
|
||||
|
||||
export const HOW_IT_WORKS_STEPS = [
|
||||
{
|
||||
icon: Users,
|
||||
title: "Create a League",
|
||||
body: "Start a league, pick your sports (we can help), and invite your friends. It takes 30 seconds to set the stage.",
|
||||
},
|
||||
{
|
||||
icon: Shuffle,
|
||||
title: "Draft Your Teams",
|
||||
body: "Pick teams from all the sports in order to build a superteam to win you bragging rights! Draft your way, slow or fast, with standard timers or high-pressure chess clocks.",
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: "Watch & Win",
|
||||
body: "Your teams earn points for how well they perform in the playoffs. Follow your rooting interests along the way, and gloat over your leaguemates when they fall.",
|
||||
},
|
||||
];
|
||||
|
||||
export const FEATURES = [
|
||||
{
|
||||
title: "Dozens of Sports and Counting",
|
||||
body: "Draft from a massive selection of sports including major US leagues, college athletics, and global niches like professional darts or snooker. We are constantly adding new sports to ensure your league has competitive action throughout the entire year.",
|
||||
},
|
||||
{
|
||||
title: "The Smart Draft Room",
|
||||
body: "Our interface supports any pace. Use a tactical chess clock with Fischer increments to reward fast decisions, or run a multi-day slow draft with customizable overnight pauses. The system ensures no one is forced to make a pick in the middle of the night.",
|
||||
},
|
||||
{
|
||||
title: "Your Master Calendar",
|
||||
body: "Stop switching between different apps to track your roster. Every game, match, and tournament for your specific teams is synced into a single view. You will always know exactly when your points are on the line without any manual tracking.",
|
||||
},
|
||||
{
|
||||
title: "Zero Maintenance",
|
||||
body: "Brackt is a draft and done experience. Once the draft is finished, your work is over because we handle all scoring and rankings automatically. You can even paste a Discord webhook URL to get live scoring updates sent directly to your group chat.",
|
||||
},
|
||||
];
|
||||
225
app/models/__tests__/draft-pick.test.ts
Normal file
225
app/models/__tests__/draft-pick.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ── DB mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_SEASON = {
|
||||
id: "season-1",
|
||||
pointsFor1st: 100, pointsFor2nd: 75, pointsFor3rd: 50,
|
||||
pointsFor4th: 40, pointsFor5th: 30, pointsFor6th: 20,
|
||||
pointsFor7th: 10, pointsFor8th: 5,
|
||||
};
|
||||
|
||||
type PickRow = {
|
||||
participant: {
|
||||
id: string;
|
||||
name: string;
|
||||
sportsSeasonId: string;
|
||||
sportsSeason: { id: string; scoringPattern: string };
|
||||
results: { finalPosition: number | null }[];
|
||||
};
|
||||
};
|
||||
|
||||
interface MakeDbOpts {
|
||||
season?: typeof DEFAULT_SEASON | null;
|
||||
picks?: PickRow[];
|
||||
scoringEvents?: { sportsSeasonId: string; bracketTemplateId: string | null }[];
|
||||
qualifyingTotals?: { participantId: string; totalQualifyingPoints: string }[];
|
||||
}
|
||||
|
||||
function makeDb(opts: MakeDbOpts = {}) {
|
||||
const {
|
||||
season = DEFAULT_SEASON,
|
||||
picks = [],
|
||||
scoringEvents = [],
|
||||
qualifyingTotals = [],
|
||||
} = opts;
|
||||
|
||||
return {
|
||||
query: {
|
||||
seasons: {
|
||||
findFirst: vi.fn().mockResolvedValue(season),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue(picks),
|
||||
},
|
||||
scoringEvents: {
|
||||
findMany: vi.fn().mockResolvedValue(scoringEvents),
|
||||
},
|
||||
participantQualifyingTotals: {
|
||||
findMany: vi.fn().mockResolvedValue(qualifyingTotals),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
import { getDraftedParticipantsWithPoints } from "../draft-pick";
|
||||
|
||||
function makePick(overrides: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
sportsSeasonId?: string;
|
||||
scoringPattern?: string;
|
||||
finalPosition?: number | null;
|
||||
}): PickRow {
|
||||
const {
|
||||
id = "p-1",
|
||||
name = "Participant One",
|
||||
sportsSeasonId = "ss-1",
|
||||
scoringPattern = "playoff_bracket",
|
||||
finalPosition = null,
|
||||
} = overrides;
|
||||
return {
|
||||
participant: {
|
||||
id,
|
||||
name,
|
||||
sportsSeasonId,
|
||||
sportsSeason: { id: sportsSeasonId, scoringPattern },
|
||||
results: finalPosition !== null ? [{ finalPosition }] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getDraftedParticipantsWithPoints", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns empty map when season not found", async () => {
|
||||
const db = makeDb({ season: null });
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty map when no picks", async () => {
|
||||
const db = makeDb({ picks: [] });
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
describe("playoff_bracket pattern", () => {
|
||||
it("returns earnedPoints based on finalPosition and scoring rules", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: 1 })],
|
||||
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
const ssEntry = result.get("ss-1");
|
||||
expect(ssEntry).toBeDefined();
|
||||
expect(ssEntry?.[0]).toMatchObject({
|
||||
id: "p-1",
|
||||
name: "Participant One",
|
||||
earnedPoints: 100, // pointsFor1st
|
||||
currentQP: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null earnedPoints when no finalPosition yet", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: null })],
|
||||
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(result.get("ss-1")?.[0]).toMatchObject({ earnedPoints: null, currentQP: null });
|
||||
});
|
||||
|
||||
it("groups multiple picks by sportsSeasonId", async () => {
|
||||
const db = makeDb({
|
||||
picks: [
|
||||
makePick({ id: "p-1", name: "Alice", sportsSeasonId: "ss-1", scoringPattern: "playoff_bracket", finalPosition: 1 }),
|
||||
makePick({ id: "p-2", name: "Bob", sportsSeasonId: "ss-1", scoringPattern: "playoff_bracket", finalPosition: 2 }),
|
||||
makePick({ id: "p-3", name: "Carol", sportsSeasonId: "ss-2", scoringPattern: "playoff_bracket", finalPosition: 1 }),
|
||||
],
|
||||
scoringEvents: [
|
||||
{ sportsSeasonId: "ss-1", bracketTemplateId: null },
|
||||
{ sportsSeasonId: "ss-2", bracketTemplateId: null },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(result.get("ss-1")).toHaveLength(2);
|
||||
expect(result.get("ss-2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifying_points pattern — active (no finalPosition)", () => {
|
||||
it("returns currentQP from participantQualifyingTotals", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: null })],
|
||||
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "42.5" }],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(result.get("ss-1")?.[0]).toMatchObject({
|
||||
earnedPoints: null,
|
||||
currentQP: 42.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null currentQP when participant has no QP total row", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: null })],
|
||||
qualifyingTotals: [],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(result.get("ss-1")?.[0]).toMatchObject({ earnedPoints: null, currentQP: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifying_points pattern — finalized (has finalPosition)", () => {
|
||||
it("returns earnedPoints via calculateFantasyPoints when finalPosition set", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: 2 })],
|
||||
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "99" }],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(result.get("ss-1")?.[0]).toMatchObject({
|
||||
earnedPoints: 75, // pointsFor2nd
|
||||
currentQP: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("season_standings pattern", () => {
|
||||
it("returns earnedPoints via calculateFantasyPoints", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "season_standings", finalPosition: 3 })],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(result.get("ss-1")?.[0]).toMatchObject({
|
||||
earnedPoints: 50, // pointsFor3rd
|
||||
currentQP: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not query scoringEvents when no playoff_bracket picks", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ scoringPattern: "season_standings", finalPosition: 1 })],
|
||||
});
|
||||
|
||||
await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(db.query.scoringEvents.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not query participantQualifyingTotals when no qualifying_points picks", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: null })],
|
||||
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
|
||||
});
|
||||
|
||||
await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(db.query.participantQualifyingTotals.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
360
app/models/__tests__/team-score-events.test.ts
Normal file
360
app/models/__tests__/team-score-events.test.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/lib/logger", () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── DB mock helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function makeInsertChain() {
|
||||
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
return { insert, values, onConflictDoUpdate };
|
||||
}
|
||||
|
||||
interface MakeDbOpts {
|
||||
sportsSeason?: { sport: { name: string } } | null;
|
||||
seasonSports?: { seasonId: string }[];
|
||||
picks?: { teamId: string; seasonId: string }[];
|
||||
seasons?: {
|
||||
id: string;
|
||||
pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number;
|
||||
pointsFor4th: number; pointsFor5th: number; pointsFor6th: number;
|
||||
pointsFor7th: number; pointsFor8th: number;
|
||||
}[];
|
||||
scoreEventRows?: {
|
||||
id: string; teamId: string; teamName?: string;
|
||||
scoringEventId: string | null; scoringEventName: string | null;
|
||||
sportName: string | null; pointsDelta: string; occurredAt: Date;
|
||||
participantIds: string[];
|
||||
team: { id: string; name: string };
|
||||
}[];
|
||||
participantRows?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
function makeDb(opts: MakeDbOpts = {}) {
|
||||
const {
|
||||
sportsSeason = null,
|
||||
seasonSports = [],
|
||||
picks = [],
|
||||
seasons = [],
|
||||
scoreEventRows = [],
|
||||
participantRows = [],
|
||||
} = opts;
|
||||
|
||||
const chain = makeInsertChain();
|
||||
|
||||
return {
|
||||
db: {
|
||||
insert: chain.insert,
|
||||
query: {
|
||||
sportsSeasons: {
|
||||
findFirst: vi.fn().mockResolvedValue(sportsSeason),
|
||||
},
|
||||
seasonSports: {
|
||||
findMany: vi.fn().mockResolvedValue(seasonSports),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue(picks),
|
||||
},
|
||||
seasons: {
|
||||
findMany: vi.fn().mockResolvedValue(seasons),
|
||||
},
|
||||
teamScoreEvents: {
|
||||
findMany: vi.fn().mockResolvedValue(scoreEventRows),
|
||||
},
|
||||
participants: {
|
||||
findMany: vi.fn().mockResolvedValue(participantRows),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
chain,
|
||||
};
|
||||
}
|
||||
|
||||
import { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events";
|
||||
|
||||
const BASE_PARAMS = {
|
||||
teamId: "team-1",
|
||||
seasonId: "season-1",
|
||||
scoringEventId: "event-1",
|
||||
scoringEventName: "Round of 16",
|
||||
sportName: "Darts",
|
||||
participantIds: ["p-1"],
|
||||
pointsDelta: 10,
|
||||
};
|
||||
|
||||
describe("recordTeamScoreEvent", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("uses matchId-based conflict target when matchId provided", async () => {
|
||||
const { db, chain } = makeDb();
|
||||
|
||||
await recordTeamScoreEvent({ ...BASE_PARAMS, matchId: "match-1" }, db);
|
||||
|
||||
expect(chain.insert).toHaveBeenCalled();
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ matchId: "match-1", pointsDelta: "10" })
|
||||
);
|
||||
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
|
||||
expect(conflictArg.target).toHaveLength(3);
|
||||
// targetWhere should reference matchId IS NOT NULL
|
||||
expect(conflictArg.targetWhere).toBeDefined();
|
||||
expect(conflictArg.set).toMatchObject({ pointsDelta: "10", participantIds: ["p-1"] });
|
||||
});
|
||||
|
||||
it("uses scoringEventId-based conflict target when no matchId", async () => {
|
||||
const { db, chain } = makeDb();
|
||||
|
||||
await recordTeamScoreEvent(BASE_PARAMS, db);
|
||||
|
||||
expect(chain.insert).toHaveBeenCalled();
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ matchId: null })
|
||||
);
|
||||
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
|
||||
expect(conflictArg.target).toHaveLength(3);
|
||||
expect(conflictArg.targetWhere).toBeDefined();
|
||||
});
|
||||
|
||||
it("stores pointsDelta as string", async () => {
|
||||
const { db, chain } = makeDb();
|
||||
await recordTeamScoreEvent({ ...BASE_PARAMS, pointsDelta: 42 }, db);
|
||||
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ pointsDelta: "42" }));
|
||||
});
|
||||
|
||||
it("defaults matchId to null when not provided", async () => {
|
||||
const { db, chain } = makeDb();
|
||||
await recordTeamScoreEvent(BASE_PARAMS, db);
|
||||
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ matchId: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordMatchScoreEvents", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const BASE_EVENT = {
|
||||
participantId: "p-1",
|
||||
sportsSeasonId: "ss-1",
|
||||
oldFloor: 0,
|
||||
newFloor: 2,
|
||||
bracketTemplateId: null as string | null,
|
||||
matchId: "match-1",
|
||||
eventId: "event-1",
|
||||
eventName: "Semifinals",
|
||||
};
|
||||
|
||||
const SEASON_ROW = {
|
||||
id: "season-1",
|
||||
pointsFor1st: 100, pointsFor2nd: 75, pointsFor3rd: 50,
|
||||
pointsFor4th: 40, pointsFor5th: 30, pointsFor6th: 20,
|
||||
pointsFor7th: 10, pointsFor8th: 5,
|
||||
};
|
||||
|
||||
it("returns early when no fantasy seasons use this sports season", async () => {
|
||||
const { db, chain } = makeDb({
|
||||
sportsSeason: { sport: { name: "Darts" } },
|
||||
seasonSports: [],
|
||||
});
|
||||
|
||||
await recordMatchScoreEvents(BASE_EVENT, db);
|
||||
|
||||
expect(chain.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when no draft picks found for participant", async () => {
|
||||
const { db, chain } = makeDb({
|
||||
sportsSeason: { sport: { name: "Darts" } },
|
||||
seasonSports: [{ seasonId: "season-1" }],
|
||||
picks: [],
|
||||
seasons: [SEASON_ROW],
|
||||
});
|
||||
|
||||
await recordMatchScoreEvents(BASE_EVENT, db);
|
||||
|
||||
expect(chain.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips seasons where team did not draft the participant", async () => {
|
||||
const { db, chain } = makeDb({
|
||||
sportsSeason: { sport: { name: "Darts" } },
|
||||
seasonSports: [{ seasonId: "season-1" }, { seasonId: "season-2" }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1" }], // only season-1 has pick
|
||||
seasons: [SEASON_ROW, { ...SEASON_ROW, id: "season-2" }],
|
||||
});
|
||||
|
||||
await recordMatchScoreEvents(BASE_EVENT, db);
|
||||
|
||||
expect(chain.insert).toHaveBeenCalledTimes(1);
|
||||
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ seasonId: "season-1" }));
|
||||
});
|
||||
|
||||
it("skips seasons where point delta is zero or negative", async () => {
|
||||
const { db, chain } = makeDb({
|
||||
sportsSeason: { sport: { name: "Darts" } },
|
||||
seasonSports: [{ seasonId: "season-1" }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
||||
seasons: [{ ...SEASON_ROW, pointsFor1st: 0, pointsFor2nd: 0 }], // all zeros → delta=0
|
||||
});
|
||||
|
||||
// With all-zero scoring rules, positions map to 0 points → delta = 0 → skip
|
||||
await recordMatchScoreEvents({ ...BASE_EVENT, oldFloor: 1, newFloor: 2 }, db);
|
||||
|
||||
expect(chain.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records score event with correct teamId, seasonId, and matchId", async () => {
|
||||
const { db, chain } = makeDb({
|
||||
sportsSeason: { sport: { name: "Darts" } },
|
||||
seasonSports: [{ seasonId: "season-1" }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
||||
seasons: [SEASON_ROW],
|
||||
});
|
||||
|
||||
await recordMatchScoreEvents(BASE_EVENT, db);
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: "team-1",
|
||||
seasonId: "season-1",
|
||||
matchId: "match-1",
|
||||
sportName: "Darts",
|
||||
participantIds: ["p-1"],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses sport name from sportsSeasons query", async () => {
|
||||
const { db, chain } = makeDb({
|
||||
sportsSeason: { sport: { name: "Snooker" } },
|
||||
seasonSports: [{ seasonId: "season-1" }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
||||
seasons: [SEASON_ROW],
|
||||
});
|
||||
|
||||
await recordMatchScoreEvents(BASE_EVENT, db);
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sportName: "Snooker" })
|
||||
);
|
||||
});
|
||||
|
||||
it("handles null sportsSeason gracefully (sportName = null)", async () => {
|
||||
const { db, chain } = makeDb({
|
||||
sportsSeason: null,
|
||||
seasonSports: [{ seasonId: "season-1" }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
||||
seasons: [SEASON_ROW],
|
||||
});
|
||||
|
||||
await recordMatchScoreEvents(BASE_EVENT, db);
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sportName: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecentTeamScoreEvents", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns empty array when no rows found", async () => {
|
||||
const { db } = makeDb({ scoreEventRows: [] });
|
||||
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not query participants when rows array is empty", async () => {
|
||||
const { db } = makeDb({ scoreEventRows: [] });
|
||||
await getRecentTeamScoreEvents("season-1", 10, db);
|
||||
expect(db.query.participants.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns mapped entries with resolved participant names", async () => {
|
||||
const occurredAt = new Date("2024-01-15");
|
||||
const { db } = makeDb({
|
||||
scoreEventRows: [
|
||||
{
|
||||
id: "tse-1",
|
||||
teamId: "team-1",
|
||||
scoringEventId: "event-1",
|
||||
scoringEventName: "Semifinals",
|
||||
sportName: "Darts",
|
||||
pointsDelta: "75",
|
||||
occurredAt,
|
||||
participantIds: ["p-1", "p-2"],
|
||||
team: { id: "team-1", name: "Team Alpha" },
|
||||
},
|
||||
],
|
||||
participantRows: [
|
||||
{ id: "p-1", name: "Luke Littler" },
|
||||
{ id: "p-2", name: "Michael van Gerwen" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "tse-1",
|
||||
teamId: "team-1",
|
||||
teamName: "Team Alpha",
|
||||
scoringEventId: "event-1",
|
||||
scoringEventName: "Semifinals",
|
||||
sportName: "Darts",
|
||||
pointsDelta: "75",
|
||||
occurredAt,
|
||||
});
|
||||
expect(result[0].participants).toEqual([
|
||||
{ id: "p-1", name: "Luke Littler" },
|
||||
{ id: "p-2", name: "Michael van Gerwen" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits participants whose names cannot be resolved", async () => {
|
||||
const { db } = makeDb({
|
||||
scoreEventRows: [
|
||||
{
|
||||
id: "tse-1",
|
||||
teamId: "team-1",
|
||||
scoringEventId: "event-1",
|
||||
scoringEventName: null,
|
||||
sportName: null,
|
||||
pointsDelta: "50",
|
||||
occurredAt: new Date(),
|
||||
participantIds: ["p-1", "p-missing"],
|
||||
team: { id: "team-1", name: "Team Beta" },
|
||||
},
|
||||
],
|
||||
participantRows: [{ id: "p-1", name: "Known Player" }],
|
||||
});
|
||||
|
||||
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
||||
|
||||
expect(result[0].participants).toEqual([{ id: "p-1", name: "Known Player" }]);
|
||||
});
|
||||
|
||||
it("handles null participantIds gracefully", async () => {
|
||||
const { db } = makeDb({
|
||||
scoreEventRows: [
|
||||
{
|
||||
id: "tse-1",
|
||||
teamId: "team-1",
|
||||
scoringEventId: "event-1",
|
||||
scoringEventName: null,
|
||||
sportName: null,
|
||||
pointsDelta: "10",
|
||||
occurredAt: new Date(),
|
||||
participantIds: null as any,
|
||||
team: { id: "team-1", name: "Team Gamma" },
|
||||
},
|
||||
],
|
||||
participantRows: [],
|
||||
});
|
||||
|
||||
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
||||
|
||||
expect(result[0].participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
12
app/models/autodraft-settings.ts
Normal file
12
app/models/autodraft-settings.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
export type AutodraftSetting = typeof schema.autodraftSettings.$inferSelect;
|
||||
|
||||
export async function getSeasonAutodraftSettings(seasonId: string): Promise<AutodraftSetting[]> {
|
||||
const db = database();
|
||||
return await db.query.autodraftSettings.findMany({
|
||||
where: eq(schema.autodraftSettings.seasonId, seasonId),
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import { getUserDisplayName } from "~/models/user";
|
|||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getEventResults } from "./event-result";
|
||||
import { getQPForPlacement, recalculateParticipantQP, getQPStandings, updateFinalRankings } from "./qualifying-points";
|
||||
import { getParticipantEV } from "./participant-expected-value";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
|
||||
/**
|
||||
* Core scoring calculation engine
|
||||
|
|
@ -285,18 +289,34 @@ export async function processPlayoffEvent(
|
|||
throw new Error("Finals match is not complete");
|
||||
}
|
||||
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, config.winnerPosition ?? 1, db);
|
||||
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
|
||||
const loserOldFloor = await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
|
||||
if (loserOldFloor !== null) {
|
||||
await recordMatchScoreEvents(
|
||||
{ participantId: finalMatch.loserId, sportsSeasonId: event.sportsSeasonId, oldFloor: loserOldFloor,
|
||||
newFloor: config.loserPosition, bracketTemplateId: event.bracketTemplateId ?? null,
|
||||
matchId: finalMatch.id, eventId, eventName: event.name },
|
||||
db
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// All other scoring rounds: assign loser's final (or provisional) position.
|
||||
for (const match of matches) {
|
||||
if (match.loserId) {
|
||||
await upsertParticipantResult(
|
||||
const loserOldFloor = await upsertParticipantResult(
|
||||
match.loserId,
|
||||
event.sportsSeasonId,
|
||||
config.loserPosition,
|
||||
db,
|
||||
config.loserIsPartial
|
||||
);
|
||||
if (loserOldFloor !== null) {
|
||||
await recordMatchScoreEvents(
|
||||
{ participantId: match.loserId, sportsSeasonId: event.sportsSeasonId, oldFloor: loserOldFloor,
|
||||
newFloor: config.loserPosition, bracketTemplateId: event.bracketTemplateId ?? null,
|
||||
matchId: match.id, eventId, eventName: event.name },
|
||||
db
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -414,7 +434,14 @@ export async function processMatchResult(
|
|||
}
|
||||
} else if (config.winnerFloor === null) {
|
||||
// Finalization round: winner and loser both get their exact positions.
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
|
||||
const loserOldFloor = await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
|
||||
if (loserOldFloor !== null && matchId && eventId) {
|
||||
await recordMatchScoreEvents(
|
||||
{ participantId: loserId, sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition,
|
||||
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
|
||||
db
|
||||
);
|
||||
}
|
||||
const winnerPosition = config.winnerPosition ?? 1;
|
||||
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, winnerPosition, db);
|
||||
if (oldFloor !== null && matchId && eventId) {
|
||||
|
|
@ -426,7 +453,14 @@ export async function processMatchResult(
|
|||
}
|
||||
} else {
|
||||
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
||||
const loserOldFloor = await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
||||
if (loserOldFloor !== null && matchId && eventId) {
|
||||
await recordMatchScoreEvents(
|
||||
{ participantId: loserId, sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition,
|
||||
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
|
||||
db
|
||||
);
|
||||
}
|
||||
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
|
||||
if (oldFloor !== null && matchId && eventId) {
|
||||
await recordMatchScoreEvents(
|
||||
|
|
@ -556,10 +590,6 @@ export async function processQualifyingEvent(
|
|||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
// Import qualifying points functions
|
||||
const { getEventResults } = await import("./event-result");
|
||||
const { getQPForPlacement, recalculateParticipantQP } = await import("./qualifying-points");
|
||||
|
||||
// Get the event
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
|
|
@ -654,9 +684,6 @@ export async function finalizeQualifyingPoints(
|
|||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
// Import qualifying points functions
|
||||
const { getQPStandings, updateFinalRankings } = await import("./qualifying-points");
|
||||
|
||||
// Get all participants ranked by total QP (highest first)
|
||||
const standings = await getQPStandings(sportsSeasonId, db);
|
||||
|
||||
|
|
@ -1092,10 +1119,6 @@ export async function calculateTeamProjectedScore(
|
|||
// Get EVs for unfinished participants
|
||||
let evSum = 0;
|
||||
if (unfinishedParticipants.length > 0) {
|
||||
// Import the participant EV model
|
||||
const { getParticipantEV } = await import("./participant-expected-value");
|
||||
const { calculateEV } = await import("~/services/ev-calculator");
|
||||
|
||||
for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) {
|
||||
const ev = await getParticipantEV(participantId, sportsSeasonId);
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,17 @@ export async function deleteSeason(id: string): Promise<void> {
|
|||
await db.delete(schema.seasons).where(eq(schema.seasons.id, id));
|
||||
}
|
||||
|
||||
export async function findSeasonWithTeamsAndLeague(seasonId: string) {
|
||||
const db = database();
|
||||
return await db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, seasonId),
|
||||
with: {
|
||||
league: true,
|
||||
teams: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findSeasonWithSportsSeasons(
|
||||
id: string
|
||||
): Promise<SeasonWithSportsSeasons | undefined> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Link } from "react-router";
|
||||
import { Button } from "app/components/ui/button";
|
||||
import { ScoringPointsTable, QualifyingPointsTable } from "~/components/marketing/ScoringTables";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
|
|
@ -73,58 +74,7 @@ export default function HowToPlay() {
|
|||
can adjust point values before the draft, but here's what a typical
|
||||
league looks like:
|
||||
</p>
|
||||
<div className="bg-muted rounded-lg overflow-hidden mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-3 font-semibold text-foreground">
|
||||
Finish
|
||||
</th>
|
||||
<th className="text-right p-3 font-semibold text-foreground">
|
||||
Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr>
|
||||
<td className="p-3">1st Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
100
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">2nd Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
70
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">3rd Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
50
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">4th Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
40
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">5th–6th Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
25
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">7th–8th Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
15
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ScoringPointsTable className="mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If nobody in your league drafted a team that finishes in the top 8,
|
||||
those points aren't awarded to anyone in your league. In other
|
||||
|
|
@ -149,70 +99,7 @@ export default function HowToPlay() {
|
|||
</span>{" "}
|
||||
at each one:
|
||||
</p>
|
||||
<div className="bg-muted rounded-lg overflow-hidden mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-3 font-semibold text-foreground">
|
||||
Major Finish
|
||||
</th>
|
||||
<th className="text-right p-3 font-semibold text-foreground">
|
||||
Qualifying Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr>
|
||||
<td className="p-3">1st</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
20 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">2nd</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
14 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">3rd</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
10 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">4th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
8 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">5th–6th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
5 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">7th–8th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
3 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">9th–12th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
2 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">13th–16th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
1 QP
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<QualifyingPointsTable className="mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
QP don't go straight to your score — they determine the final
|
||||
ranking within that sport. Once all the majors are done, total QP
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { useLoaderData, Link } from "react-router";
|
||||
import { eq, asc, and, inArray } from "drizzle-orm";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { eq, asc, inArray } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { requireLeagueAccess } from "~/lib/auth";
|
||||
import { DraftGrid } from "~/components/DraftGrid";
|
||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||
import { useState, useEffect } from "react";
|
||||
import { buildOwnerMap } from "~/lib/owner-map";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
|
||||
import logomarkUrl from "../../../public/logomark.svg?url";
|
||||
import { computeCoronaStates } from "~/lib/corona-states";
|
||||
import type { CoronaState } from "~/components/draft/DraftPickCell";
|
||||
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
|
||||
|
||||
|
|
@ -47,32 +47,13 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
|
||||
// Check access: public boards are accessible to everyone without auth
|
||||
if (!season.league.isPublicDraftBoard) {
|
||||
// Not public - check if the user is a league member or commissioner
|
||||
const { userId } = await getAuth(args);
|
||||
|
||||
if (!userId) {
|
||||
throw new Response("This draft board is not public", { status: 403 });
|
||||
}
|
||||
|
||||
// Check if user is a commissioner
|
||||
const isCommissioner = await db.query.commissioners.findFirst({
|
||||
where: and(
|
||||
eq(schema.commissioners.leagueId, season.leagueId),
|
||||
eq(schema.commissioners.userId, userId)
|
||||
),
|
||||
await requireLeagueAccess(args, {
|
||||
leagueId: season.leagueId,
|
||||
seasonId,
|
||||
db,
|
||||
unauthMessage: "This draft board is not public",
|
||||
unauthorizedMessage: "You don't have access to this draft board",
|
||||
});
|
||||
|
||||
// Check if user has a team in this season
|
||||
const hasTeam = await db.query.teams.findFirst({
|
||||
where: and(
|
||||
eq(schema.teams.seasonId, seasonId),
|
||||
eq(schema.teams.ownerId, userId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!isCommissioner && !hasTeam) {
|
||||
throw new Response("You don't have access to this draft board", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Get draft slots (draft order)
|
||||
|
|
@ -115,13 +96,11 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
|
||||
const ownerMap = await buildOwnerMap(draftSlots);
|
||||
|
||||
const coronaStates: Record<string, CoronaState> = {};
|
||||
let coronaStates: Record<string, CoronaState> = {};
|
||||
|
||||
if (draftPicks.length > 0) {
|
||||
const participantIds = draftPicks.map((p) => p.participant.id);
|
||||
const sportsSeasonIds = [
|
||||
...new Set(draftPicks.map((p) => p.participant.sportsSeasonId)),
|
||||
];
|
||||
const sportsSeasonIds = [...new Set(draftPicks.map((p) => p.participant.sportsSeasonId))];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
|
|
@ -133,11 +112,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
.from(schema.participantResults)
|
||||
.where(inArray(schema.participantResults.participantId, participantIds));
|
||||
|
||||
const resultByParticipant = new Map(
|
||||
results.map((r) => [r.participantId, r])
|
||||
);
|
||||
|
||||
const maxPoints = season.pointsFor1st;
|
||||
const resultByParticipant = new Map(results.map((r) => [r.participantId, r]));
|
||||
|
||||
const bracketTemplateBySportsSeason = new Map<string, string | null>();
|
||||
if (sportsSeasonIds.length > 0) {
|
||||
|
|
@ -150,10 +125,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
.where(inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds));
|
||||
for (const ev of events) {
|
||||
if (!bracketTemplateBySportsSeason.has(ev.sportsSeasonId)) {
|
||||
bracketTemplateBySportsSeason.set(
|
||||
ev.sportsSeasonId,
|
||||
ev.bracketTemplateId
|
||||
);
|
||||
bracketTemplateBySportsSeason.set(ev.sportsSeasonId, ev.bracketTemplateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -169,46 +141,13 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
pointsFor8th: season.pointsFor8th,
|
||||
};
|
||||
|
||||
for (const pick of draftPicks) {
|
||||
const result = resultByParticipant.get(pick.participant.id);
|
||||
|
||||
if (!result || result.finalPosition === null) {
|
||||
coronaStates[pick.participant.id] = { type: "pending" };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.finalPosition === 0 && !result.isPartialScore) {
|
||||
coronaStates[pick.participant.id] = { type: "eliminated", points: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.finalPosition > 0) {
|
||||
const isBracket =
|
||||
pick.scoringPattern === "playoff_bracket";
|
||||
const templateId = isBracket
|
||||
? bracketTemplateBySportsSeason.get(
|
||||
pick.participant.sportsSeasonId
|
||||
) ?? null
|
||||
: null;
|
||||
const points = isBracket
|
||||
? calculateBracketPoints(
|
||||
result.finalPosition,
|
||||
scoringRules,
|
||||
templateId
|
||||
)
|
||||
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||
const brightness =
|
||||
maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
|
||||
coronaStates[pick.participant.id] = {
|
||||
type: "scored",
|
||||
brightness,
|
||||
points,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
coronaStates[pick.participant.id] = { type: "pending" };
|
||||
}
|
||||
coronaStates = computeCoronaStates(
|
||||
draftPicks,
|
||||
resultByParticipant,
|
||||
bracketTemplateBySportsSeason,
|
||||
scoringRules,
|
||||
season.pointsFor1st,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
import { useLoaderData, Link } from "react-router";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { database } from "~/database/context";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import { requireLeagueAccess } from "~/lib/auth";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
|
||||
|
|
@ -21,7 +21,6 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
try {
|
||||
const { userId } = await getAuth(args);
|
||||
const { params } = args;
|
||||
const { leagueId, seasonId } = params;
|
||||
|
||||
|
|
@ -46,34 +45,14 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
}
|
||||
|
||||
// Check access
|
||||
if (!userId) {
|
||||
throw new Response("You must be logged in to view standings", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is a commissioner
|
||||
const isUserCommissioner = await db.query.commissioners.findFirst({
|
||||
where: and(
|
||||
eq(schema.commissioners.leagueId, leagueId),
|
||||
eq(schema.commissioners.userId, userId)
|
||||
),
|
||||
await requireLeagueAccess(args, {
|
||||
leagueId,
|
||||
seasonId,
|
||||
db,
|
||||
unauthMessage: "You must be logged in to view standings",
|
||||
unauthorizedMessage: "You do not have access to this league",
|
||||
});
|
||||
|
||||
// Check if user has a team in this season
|
||||
const hasTeam = await db.query.teams.findFirst({
|
||||
where: and(
|
||||
eq(schema.teams.seasonId, seasonId),
|
||||
eq(schema.teams.ownerId, userId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!isUserCommissioner && !hasTeam) {
|
||||
throw new Response("You do not have access to this league", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch standings, teams (for owner lookup), and independent data in parallel
|
||||
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage, recentScoreEvents] =
|
||||
await Promise.all([
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { ScoringPointsTable, QualifyingPointsTable } from "~/components/marketing/ScoringTables";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: "Rules - Brackt" },
|
||||
|
|
@ -69,58 +71,7 @@ export default function Rules() {
|
|||
begins. To find out how a specific sport determines its final
|
||||
standings, visit the corresponding sport page.
|
||||
</p>
|
||||
<div className="bg-muted rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-3 font-semibold text-foreground">
|
||||
Finish
|
||||
</th>
|
||||
<th className="text-right p-3 font-semibold text-foreground">
|
||||
Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr>
|
||||
<td className="p-3">1st Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
100
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">2nd Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
70
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">3rd Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
50
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">4th Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
40
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">5th–6th Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
25
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">7th–8th Place</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
15
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ScoringPointsTable />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
|
|
@ -175,70 +126,7 @@ export default function Rules() {
|
|||
based on their finish:
|
||||
</p>
|
||||
|
||||
<div className="bg-muted rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-3 font-semibold text-foreground">
|
||||
Finish
|
||||
</th>
|
||||
<th className="text-right p-3 font-semibold text-foreground">
|
||||
Qualifying Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr>
|
||||
<td className="p-3">1st</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
20 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">2nd</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
14 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">3rd</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
10 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">4th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
8 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">5th–6th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
5 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">7th–8th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
3 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">9th–12th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
2 QP
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3">13th–16th</td>
|
||||
<td className="p-3 text-right font-semibold text-foreground">
|
||||
1 QP
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<QualifyingPointsTable />
|
||||
|
||||
<p>
|
||||
Qualifying points are not added to a participant's total league
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue