Mobile draft room improvements: watchlist, collapsible picks feed, autodraft controls
- Add watchlist feature: eye icon per participant, "Watched Only" filter, DB table + migration, toggle API route, socket sync on reconnect - Make RecentPicksFeed collapsible on mobile participants tab (chevron toggle, defaults expanded) - Add AutodraftSettings to mobile controls tab (was desktop-only) - Pin Pause/Resume Draft and Exit Draft Room buttons to the bottom of the mobile controls tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5268e07365
commit
c18deb1455
16 changed files with 5881 additions and 100 deletions
|
|
@ -52,6 +52,10 @@ const defaultProps = {
|
|||
onMakePick: vi.fn(),
|
||||
onAddToQueue: vi.fn(),
|
||||
onRemoveFromQueue: vi.fn(),
|
||||
watchedParticipantIds: new Set<string>(),
|
||||
onToggleWatchlist: vi.fn(),
|
||||
showOnlyWatched: false,
|
||||
onShowOnlyWatchedChange: vi.fn(),
|
||||
participantRanks: new Map([
|
||||
["1", { overallRank: 1, sportRank: 1 }],
|
||||
["2", { overallRank: 2, sportRank: 1 }],
|
||||
|
|
@ -376,12 +380,130 @@ describe("AvailableParticipantsSection", () => {
|
|||
expect(defaultProps.onHideCompletedSportsChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("Done button closes the sheet", async () => {
|
||||
it("Popover trigger opens and can be closed", async () => {
|
||||
render(<AvailableParticipantsSection {...defaultProps} />);
|
||||
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Watchlist", () => {
|
||||
it("does not render watchlist toggle when hasTeam is false", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection {...defaultProps} hasTeam={false} />
|
||||
);
|
||||
expect(screen.queryByTitle("Add to watchlist")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Remove from watchlist")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders EyeOff icon for unwatched participants when hasTeam is true", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
watchedParticipantIds={new Set<string>()}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByTitle("Add to watchlist");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders EyeOff icon for watched participants", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
watchedParticipantIds={new Set(["1"])}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByTitle("Remove from watchlist");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("calls onToggleWatchlist when eye button clicked", async () => {
|
||||
const onToggleWatchlist = vi.fn();
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
onToggleWatchlist={onToggleWatchlist}
|
||||
watchedParticipantIds={new Set<string>()}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByTitle("Add to watchlist");
|
||||
await user.click(buttons[0]);
|
||||
expect(onToggleWatchlist).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("applies emerald highlight to watched participants", () => {
|
||||
const { container } = render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
watchedParticipantIds={new Set(["1"])}
|
||||
/>
|
||||
);
|
||||
const highlighted = container.querySelector(".bg-emerald-500\\/10");
|
||||
expect(highlighted).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not highlight unwatched participants", () => {
|
||||
const { container } = render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
watchedParticipantIds={new Set<string>()}
|
||||
/>
|
||||
);
|
||||
const highlighted = container.querySelector(".bg-emerald-500\\/10");
|
||||
expect(highlighted).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Watched Only checkbox when hasTeam", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Watched Only")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Watched Only checkbox when hasTeam is false", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("Watched Only")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Watched Only checkbox when no participants are watched", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
watchedParticipantIds={new Set<string>()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByLabelText("Watched Only")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls onShowOnlyWatchedChange when Watched Only checkbox clicked", async () => {
|
||||
const onShowOnlyWatchedChange = vi.fn();
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
hasTeam={true}
|
||||
watchedParticipantIds={new Set(["1"])}
|
||||
onShowOnlyWatchedChange={onShowOnlyWatchedChange}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByLabelText("Watched Only"));
|
||||
expect(onShowOnlyWatchedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
66
app/components/__tests__/RecentPicksFeed.test.tsx
Normal file
66
app/components/__tests__/RecentPicksFeed.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RecentPicksFeed } from "~/components/draft/RecentPicksFeed";
|
||||
|
||||
const makePick = (n: number) => ({
|
||||
id: String(n),
|
||||
pickNumber: n,
|
||||
participant: { name: `Participant ${n}` },
|
||||
sport: { name: "NFL" },
|
||||
team: { name: `Team ${n}` },
|
||||
});
|
||||
|
||||
describe("RecentPicksFeed", () => {
|
||||
it("renders the Latest Picks header", () => {
|
||||
render(<RecentPicksFeed picks={[]} />);
|
||||
expect(screen.getByText("Latest Picks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows picks by default (expanded)", () => {
|
||||
render(<RecentPicksFeed picks={[makePick(1), makePick(2)]} />);
|
||||
expect(screen.getByText("Participant 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Participant 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty message when no picks and expanded", () => {
|
||||
render(<RecentPicksFeed picks={[]} />);
|
||||
expect(screen.getByText("No picks yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses picks when header button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RecentPicksFeed picks={[makePick(1), makePick(2)]} />);
|
||||
await user.click(screen.getByRole("button", { name: "Toggle latest picks" }));
|
||||
expect(screen.queryByText("Participant 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Participant 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-expands when header button is clicked again", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RecentPicksFeed picks={[makePick(1)]} />);
|
||||
const btn = screen.getByRole("button", { name: "Toggle latest picks" });
|
||||
await user.click(btn);
|
||||
await user.click(btn);
|
||||
expect(screen.getByText("Participant 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("limits display to the 3 most recent picks", () => {
|
||||
const picks = [makePick(1), makePick(2), makePick(3), makePick(4), makePick(5)];
|
||||
render(<RecentPicksFeed picks={picks} />);
|
||||
expect(screen.queryByText("Participant 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Participant 2")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Participant 3")).toBeInTheDocument();
|
||||
expect(screen.getByText("Participant 4")).toBeInTheDocument();
|
||||
expect(screen.getByText("Participant 5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets aria-expanded correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RecentPicksFeed picks={[makePick(1)]} />);
|
||||
const btn = screen.getByRole("button", { name: "Toggle latest picks" });
|
||||
expect(btn).toHaveAttribute("aria-expanded", "true");
|
||||
await user.click(btn);
|
||||
expect(btn).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useCallback, useMemo, useRef } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
SheetFooter,
|
||||
SheetClose,
|
||||
} from "~/components/ui/sheet";
|
||||
import { ListPlus, ListX, ChevronDown } from "lucide-react";
|
||||
import { ListPlus, ListX, ChevronDown, Eye, EyeOff, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
type ListItem =
|
||||
| { type: "participant"; index: number; key: string }
|
||||
|
|
@ -30,7 +30,8 @@ function getParticipantState(
|
|||
eligibility: {
|
||||
eligibleSportIds: Set<string>;
|
||||
ineligibleReasons: Record<string, string>;
|
||||
} | null
|
||||
} | null,
|
||||
watchedParticipantIds: Set<string>
|
||||
) {
|
||||
const isDrafted = draftedParticipantIds.has(participant.id);
|
||||
const isInQueue = queueMap.has(participant.id);
|
||||
|
|
@ -38,7 +39,8 @@ function getParticipantState(
|
|||
? eligibility.eligibleSportIds.has(participant.sport.id)
|
||||
: true;
|
||||
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id];
|
||||
return { isDrafted, isInQueue, isEligible, ineligibleReason };
|
||||
const isWatched = watchedParticipantIds.has(participant.id);
|
||||
return { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched };
|
||||
}
|
||||
|
||||
interface SportFilterContentProps {
|
||||
|
|
@ -162,6 +164,10 @@ interface AvailableParticipantsSectionProps {
|
|||
onAddToQueue: (participantId: string) => void;
|
||||
onRemoveFromQueue: (queueId: string) => void;
|
||||
projectedPicks?: Array<{ round: number; picksFromNow: number }>;
|
||||
watchedParticipantIds: Set<string>;
|
||||
onToggleWatchlist: (participantId: string) => void;
|
||||
showOnlyWatched: boolean;
|
||||
onShowOnlyWatchedChange: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
|
||||
|
|
@ -189,6 +195,10 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
onAddToQueue,
|
||||
onRemoveFromQueue,
|
||||
projectedPicks,
|
||||
watchedParticipantIds,
|
||||
onToggleWatchlist,
|
||||
showOnlyWatched,
|
||||
onShowOnlyWatchedChange,
|
||||
}: AvailableParticipantsSectionProps) {
|
||||
const queueMap = useMemo(
|
||||
() => new Map(queue.map((item) => [item.participantId, item.id])),
|
||||
|
|
@ -294,6 +304,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
? "grid-cols-[60px_60px_1fr_140px]"
|
||||
: "grid-cols-[60px_60px_1fr]";
|
||||
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{miniDraftGrid && (
|
||||
|
|
@ -312,46 +324,17 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
className="flex-1 min-w-0 px-3 py-2 h-9 border rounded-md text-base md:text-sm bg-background text-foreground"
|
||||
/>
|
||||
|
||||
<div className="md:hidden shrink-0">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={triggerAriaLabel}
|
||||
className="justify-between text-base font-normal"
|
||||
>
|
||||
<span className="truncate">{triggerText}</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
|
||||
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
|
||||
<SportFilterContent
|
||||
sportsForDropdown={sportsForDropdown}
|
||||
sportFilterSet={sportFilterSet}
|
||||
hideCompletedSports={hideCompletedSports}
|
||||
hasTeam={hasTeam}
|
||||
variant="sheet"
|
||||
onToggleSport={handleToggleSport}
|
||||
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
||||
/>
|
||||
<SheetFooter className="px-4 pb-8 flex-row gap-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<SheetClose asChild>
|
||||
<Button className="flex-1">Done</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:hidden shrink-0 h-9 gap-1.5"
|
||||
onClick={() => setMobileFiltersOpen((prev) => !prev)}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<div className="hidden md:block shrink-0">
|
||||
<Popover>
|
||||
|
|
@ -392,7 +375,48 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap md:contents">
|
||||
<div className={`flex gap-2 flex-wrap md:contents ${mobileFiltersOpen ? "" : "hidden md:contents"}`}>
|
||||
<div className="md:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={triggerAriaLabel}
|
||||
className="justify-between text-base font-normal w-full"
|
||||
>
|
||||
<span className="truncate">{triggerText}</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
|
||||
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
|
||||
<SportFilterContent
|
||||
sportsForDropdown={sportsForDropdown}
|
||||
sportFilterSet={sportFilterSet}
|
||||
hideCompletedSports={hideCompletedSports}
|
||||
hasTeam={hasTeam}
|
||||
variant="sheet"
|
||||
onToggleSport={handleToggleSport}
|
||||
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
||||
/>
|
||||
<SheetFooter className="px-4 pb-8 flex-row gap-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<SheetClose asChild>
|
||||
<Button className="flex-1">Done</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
|
||||
<Checkbox
|
||||
checked={!hideDrafted}
|
||||
|
|
@ -410,6 +434,17 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
<span>Show Ineligible</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{hasTeam && (
|
||||
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
|
||||
<Checkbox
|
||||
checked={showOnlyWatched}
|
||||
onCheckedChange={(checked) => onShowOnlyWatchedChange(checked === true)}
|
||||
disabled={watchedParticipantIds.size === 0}
|
||||
/>
|
||||
<span>Watched Only</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -461,8 +496,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
}
|
||||
|
||||
const participant = participants[listItem.index];
|
||||
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
|
||||
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
|
||||
const { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched } =
|
||||
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility, watchedParticipantIds);
|
||||
const rank = participantRanks.get(participant.id);
|
||||
const followsDivider = listItems[virtualItem.index - 1]?.type === "divider";
|
||||
|
||||
|
|
@ -485,7 +520,9 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
? "bg-muted/50 opacity-60"
|
||||
: !isEligible
|
||||
? "bg-destructive/10 opacity-75"
|
||||
: ""
|
||||
: isWatched
|
||||
? "bg-emerald-500/10 border-l-2 border-l-emerald-500/30"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
|
|
@ -524,6 +561,17 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
</div>
|
||||
{hasTeam && !isDrafted && (
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-[44px] min-w-[44px]"
|
||||
onClick={() => onToggleWatchlist(participant.id)}
|
||||
title={isWatched ? "Remove from watchlist" : "Add to watchlist"}
|
||||
>
|
||||
{isWatched
|
||||
? <EyeOff className="h-4 w-4 text-emerald-400" />
|
||||
: <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
{!isInQueue ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -578,7 +626,9 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
? "bg-muted/50 opacity-60"
|
||||
: !isEligible
|
||||
? "bg-destructive/10 opacity-75"
|
||||
: "hover:bg-muted/50"
|
||||
: isWatched
|
||||
? "bg-emerald-500/10 border-l-2 border-l-emerald-500/30"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
title={
|
||||
!isEligible && !isDrafted ? ineligibleReason : undefined
|
||||
|
|
@ -626,6 +676,16 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
<div className="flex gap-2 justify-end items-center">
|
||||
{!isDrafted && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleWatchlist(participant.id)}
|
||||
title={isWatched ? "Remove from watchlist" : "Add to watchlist"}
|
||||
>
|
||||
{isWatched
|
||||
? <EyeOff className="h-4 w-4 text-emerald-400" />
|
||||
: <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
{!isInQueue ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type Pick = {
|
||||
id: string;
|
||||
|
|
@ -12,6 +13,7 @@ export const RecentPicksFeed = memo(function RecentPicksFeed({ picks }: { picks:
|
|||
const recentPicks = picks.slice(-3).toReversed();
|
||||
const prevNewestIdRef = useRef<string | undefined>(picks.at(-1)?.id);
|
||||
const [animatingId, setAnimatingId] = useState<string | undefined>(undefined);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const newest = recentPicks[0];
|
||||
|
|
@ -24,32 +26,46 @@ export const RecentPicksFeed = memo(function RecentPicksFeed({ picks }: { picks:
|
|||
}, [recentPicks]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-3 pt-2 pb-1 overflow-hidden">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60 mb-0.5">Latest Picks</p>
|
||||
{picks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-1">No picks yet</p>
|
||||
) : (
|
||||
recentPicks.map((pick, index) => (
|
||||
<div
|
||||
key={pick.id}
|
||||
className={`w-full ${index === 0 && animatingId === pick.id ? "rpf-animate" : ""} bg-muted rounded-lg px-3 py-1.5 flex items-center gap-2`}
|
||||
>
|
||||
<span className="text-xs font-bold text-muted-foreground shrink-0 tabular-nums">
|
||||
#{pick.pickNumber}
|
||||
</span>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{pick.participant.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70 truncate">
|
||||
{pick.sport.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[90px] text-right shrink-0">
|
||||
{pick.team.name}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
<div className="flex flex-col px-3 pt-2 pb-1 overflow-hidden border-b">
|
||||
<button
|
||||
className="flex items-center justify-between w-full mb-0.5 py-0.5"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label="Toggle latest picks"
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">Latest Picks</p>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 text-muted-foreground/60 transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-1 mt-0.5">
|
||||
{picks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-1">No picks yet</p>
|
||||
) : (
|
||||
recentPicks.map((pick, index) => (
|
||||
<div
|
||||
key={pick.id}
|
||||
className={`w-full ${index === 0 && animatingId === pick.id ? "rpf-animate" : ""} bg-muted rounded-lg px-3 py-1.5 flex items-center gap-2`}
|
||||
>
|
||||
<span className="text-xs font-bold text-muted-foreground shrink-0 tabular-nums">
|
||||
#{pick.pickNumber}
|
||||
</span>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{pick.participant.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70 truncate">
|
||||
{pick.sport.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[90px] text-right shrink-0">
|
||||
{pick.team.name}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ interface UseDraftRoomStateParams {
|
|||
timers: Timers;
|
||||
draftSlots: DraftSlots;
|
||||
userQueue: QueueItem[];
|
||||
initialWatchedParticipantIds: string[];
|
||||
}
|
||||
|
||||
export function useDraftRoomState({
|
||||
|
|
@ -41,6 +42,7 @@ export function useDraftRoomState({
|
|||
timers,
|
||||
draftSlots,
|
||||
userQueue,
|
||||
initialWatchedParticipantIds,
|
||||
}: UseDraftRoomStateParams) {
|
||||
const [picks, setPicks] = useState(draftPicks);
|
||||
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
||||
|
|
@ -82,6 +84,12 @@ export function useDraftRoomState({
|
|||
|
||||
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
|
||||
|
||||
const [watchedParticipantIds, setWatchedParticipantIds] = useState<Set<string>>(
|
||||
() => new Set(initialWatchedParticipantIds)
|
||||
);
|
||||
|
||||
const [showOnlyWatched, setShowOnlyWatched] = useState(false);
|
||||
|
||||
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
|
||||
const timersMap: Record<string, number> = {};
|
||||
const initialTime = season.draftInitialTime || 120;
|
||||
|
|
@ -147,6 +155,8 @@ export function useDraftRoomState({
|
|||
isMobile,
|
||||
autodraftStatus, setAutodraftStatus,
|
||||
connectedTeams, setConnectedTeams,
|
||||
watchedParticipantIds, setWatchedParticipantIds,
|
||||
showOnlyWatched, setShowOnlyWatched,
|
||||
teamTimers, setTeamTimers,
|
||||
forcePickDialogOpen, setForcePickDialogOpen,
|
||||
selectedPickSlot, setSelectedPickSlot,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface UseDraftSocketEventsParams {
|
|||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||
setIsOvernightPause: (value: boolean) => void;
|
||||
setOvernightResumesAt: (value: Date | null) => void;
|
||||
setWatchedParticipantIds: (value: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useDraftSocketEvents({
|
||||
|
|
@ -64,6 +65,7 @@ export function useDraftSocketEvents({
|
|||
setTeamTimers,
|
||||
setIsOvernightPause,
|
||||
setOvernightResumesAt,
|
||||
setWatchedParticipantIds,
|
||||
}: UseDraftSocketEventsParams) {
|
||||
useEffect(() => {
|
||||
type PickMadePayload = {
|
||||
|
|
@ -199,6 +201,7 @@ export function useDraftSocketEvents({
|
|||
status: string;
|
||||
timers?: Array<{ teamId: string; timeRemaining: number }>;
|
||||
queue?: QueueItem[];
|
||||
watchlistParticipantIds?: string[];
|
||||
}) => {
|
||||
if (!isRevalidatingRef.current) {
|
||||
setPicks(() => data.picks);
|
||||
|
|
@ -216,9 +219,16 @@ export function useDraftSocketEvents({
|
|||
const q = data.queue;
|
||||
setQueue(() => q);
|
||||
}
|
||||
if (data.watchlistParticipantIds) {
|
||||
setWatchedParticipantIds(new Set(data.watchlistParticipantIds));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleWatchlistUpdated = (data: { participantIds: string[] }) => {
|
||||
setWatchedParticipantIds(new Set(data.participantIds));
|
||||
};
|
||||
|
||||
on("pick-made", handlePickMade as (data: unknown) => void);
|
||||
on("timer-update", handleTimerUpdate as (data: unknown) => void);
|
||||
on("draft-paused", handleDraftPaused as (data: unknown) => void);
|
||||
|
|
@ -234,6 +244,7 @@ export function useDraftSocketEvents({
|
|||
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);
|
||||
on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
|
||||
|
||||
return () => {
|
||||
off("pick-made", handlePickMade as (data: unknown) => void);
|
||||
|
|
@ -251,6 +262,7 @@ export function useDraftSocketEvents({
|
|||
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);
|
||||
off("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [on, off, socketVersion]);
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ export * from "./draft-pick";
|
|||
export * from "./draft-queue";
|
||||
export * from "./draft-timer";
|
||||
export * from "./draft-utils";
|
||||
export * from "./watchlist";
|
||||
export * from "./audit-log";
|
||||
|
|
|
|||
57
app/models/watchlist.ts
Normal file
57
app/models/watchlist.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export async function addToWatchlist(data: {
|
||||
seasonId: string;
|
||||
teamId: string;
|
||||
participantId: string;
|
||||
}) {
|
||||
const db = database();
|
||||
const [item] = await db.insert(schema.watchlist).values(data).returning();
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function removeFromWatchlist(data: {
|
||||
seasonId: string;
|
||||
teamId: string;
|
||||
participantId: string;
|
||||
}) {
|
||||
const db = database();
|
||||
await db
|
||||
.delete(schema.watchlist)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.watchlist.seasonId, data.seasonId),
|
||||
eq(schema.watchlist.teamId, data.teamId),
|
||||
eq(schema.watchlist.participantId, data.participantId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTeamWatchlist(teamId: string, seasonId: string) {
|
||||
const db = database();
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.watchlist)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.watchlist.teamId, teamId),
|
||||
eq(schema.watchlist.seasonId, seasonId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function isOnWatchlist(
|
||||
teamId: string,
|
||||
participantId: string
|
||||
): Promise<boolean> {
|
||||
const db = database();
|
||||
const existing = await db.query.watchlist.findFirst({
|
||||
where: and(
|
||||
eq(schema.watchlist.teamId, teamId),
|
||||
eq(schema.watchlist.participantId, participantId)
|
||||
),
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ export default [
|
|||
route("api/draft/rollback", "routes/api/draft.rollback.ts"),
|
||||
route("api/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.ts"),
|
||||
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
|
||||
route("api/watchlist/toggle", "routes/api/watchlist.toggle.ts"),
|
||||
route("api/user/timezone", "routes/api/user.timezone.ts"),
|
||||
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
||||
route("login", "routes/login.tsx"),
|
||||
|
|
|
|||
56
app/routes/api/watchlist.toggle.ts
Normal file
56
app/routes/api/watchlist.toggle.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { addToWatchlist, removeFromWatchlist, isOnWatchlist, getTeamWatchlist } from "~/models/watchlist";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
const { request } = args;
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
|
||||
if (!userId) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const seasonId = formData.get("seasonId");
|
||||
const teamId = formData.get("teamId");
|
||||
const participantId = formData.get("participantId");
|
||||
|
||||
if (!seasonId || !teamId || !participantId) {
|
||||
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = database();
|
||||
|
||||
const team = await db.query.teams.findFirst({
|
||||
where: eq(schema.teams.id, teamId as string),
|
||||
});
|
||||
|
||||
if (!team || team.ownerId !== userId) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const alreadyWatched = await isOnWatchlist(teamId as string, participantId as string);
|
||||
|
||||
if (alreadyWatched) {
|
||||
await removeFromWatchlist({ seasonId: seasonId as string, teamId: teamId as string, participantId: participantId as string });
|
||||
} else {
|
||||
await addToWatchlist({
|
||||
seasonId: seasonId as string,
|
||||
teamId: teamId as string,
|
||||
participantId: participantId as string,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedWatchlist = await getTeamWatchlist(teamId as string, seasonId as string);
|
||||
const participantIds = updatedWatchlist.map((item) => item.participantId);
|
||||
|
||||
const io = getSocketIO();
|
||||
io.to(`team-${teamId}`).emit("watchlist-updated", { participantIds });
|
||||
|
||||
return Response.json({ success: true, participantIds });
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { TimeBankAdjustmentDialog } from "~/components/draft/TimeBankAdjustmentD
|
|||
import { CommissionerAutodraftDialog } from "~/components/draft/CommissionerAutodraftDialog";
|
||||
import { CommissionerDraftControls } from "~/components/draft/CommissionerDraftControls";
|
||||
import { getTeamQueue } from "~/models/draft-queue";
|
||||
import { getTeamWatchlist } from "~/models/watchlist";
|
||||
import { findSeasonWithTeamsAndLeague } from "~/models/season";
|
||||
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
|
||||
import { getDraftPicksForSeason } from "~/models/draft-pick";
|
||||
|
|
@ -38,7 +39,7 @@ import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|||
import { getTeamForPick, getProjectedPicks } from "~/lib/draft-order";
|
||||
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
||||
import { NotificationSettings } from "~/components/NotificationSettings";
|
||||
import { AutodraftBadgeWithPopover } from "~/components/AutodraftSettings";
|
||||
import { AutodraftBadgeWithPopover, AutodraftSettings } from "~/components/AutodraftSettings";
|
||||
import { toast } from "sonner";
|
||||
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
||||
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
|
||||
|
|
@ -110,6 +111,13 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
})
|
||||
: [];
|
||||
|
||||
const userWatchlist = userTeam
|
||||
? await getTeamWatchlist(userTeam.id, seasonId).catch((err) => {
|
||||
logger.error("[DraftLoader] Failed to load watchlist, defaulting to empty:", err);
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
|
||||
const userAutodraftSettings = userTeam
|
||||
? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null)
|
||||
: null;
|
||||
|
|
@ -148,6 +156,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
availableParticipants,
|
||||
userTeam,
|
||||
userQueue,
|
||||
userWatchlist: userWatchlist.map((w) => w.participantId),
|
||||
timers,
|
||||
autodraftSettings,
|
||||
userAutodraftSettings,
|
||||
|
|
@ -166,6 +175,7 @@ export default function DraftRoom() {
|
|||
availableParticipants,
|
||||
userTeam,
|
||||
userQueue,
|
||||
userWatchlist,
|
||||
timers,
|
||||
autodraftSettings,
|
||||
userAutodraftSettings,
|
||||
|
|
@ -215,6 +225,8 @@ export default function DraftRoom() {
|
|||
isMobile,
|
||||
autodraftStatus, setAutodraftStatus,
|
||||
connectedTeams, setConnectedTeams,
|
||||
watchedParticipantIds, setWatchedParticipantIds,
|
||||
showOnlyWatched, setShowOnlyWatched,
|
||||
teamTimers, setTeamTimers,
|
||||
forcePickDialogOpen, setForcePickDialogOpen,
|
||||
selectedPickSlot, setSelectedPickSlot,
|
||||
|
|
@ -242,6 +254,7 @@ export default function DraftRoom() {
|
|||
timers,
|
||||
draftSlots,
|
||||
userQueue,
|
||||
initialWatchedParticipantIds: userWatchlist,
|
||||
});
|
||||
|
||||
// Advances every second so isInOvernightWindow sees the real current time.
|
||||
|
|
@ -418,6 +431,7 @@ export default function DraftRoom() {
|
|||
setTeamTimers,
|
||||
setIsOvernightPause,
|
||||
setOvernightResumesAt,
|
||||
setWatchedParticipantIds,
|
||||
});
|
||||
|
||||
// Persist sidebar collapsed state
|
||||
|
|
@ -571,6 +585,56 @@ export default function DraftRoom() {
|
|||
}
|
||||
}, [userTeam, season.id, authFetch, setQueue, pendingQueueMutationsRef]);
|
||||
|
||||
const handleToggleWatchlist = useCallback(async (participantId: string) => {
|
||||
if (!userTeam) return;
|
||||
|
||||
setWatchedParticipantIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(participantId)) {
|
||||
next.delete(participantId);
|
||||
} else {
|
||||
next.add(participantId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", season.id);
|
||||
formData.append("teamId", userTeam.id);
|
||||
formData.append("participantId", participantId);
|
||||
|
||||
try {
|
||||
const response = await authFetch("/api/watchlist/toggle", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response) {
|
||||
setWatchedParticipantIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setWatchedParticipantIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
|
||||
return next;
|
||||
});
|
||||
const error = await response.json();
|
||||
toast.error(error.error || "Failed to update watchlist");
|
||||
}
|
||||
} catch {
|
||||
setWatchedParticipantIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
|
||||
return next;
|
||||
});
|
||||
toast.error("Network error - failed to update watchlist");
|
||||
}
|
||||
}, [userTeam, season.id, authFetch, setWatchedParticipantIds]);
|
||||
|
||||
const handleStartDraft = async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
|
@ -986,9 +1050,13 @@ export default function DraftRoom() {
|
|||
if (sportFilterSet.size > 0 && !sportFilterSet.has(participant.sport.name)) {
|
||||
return false;
|
||||
}
|
||||
// Watched only filter
|
||||
if (showOnlyWatched && !watchedParticipantIds.has(participant.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters]);
|
||||
}, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]);
|
||||
|
||||
// Shared component props — defined once to avoid duplication between desktop and mobile layouts
|
||||
const handleHideCompletedSportsChange = useCallback((hide: boolean) => {
|
||||
|
|
@ -1043,7 +1111,11 @@ export default function DraftRoom() {
|
|||
onAddToQueue: handleAddToQueue,
|
||||
onRemoveFromQueue: handleRemoveFromQueue,
|
||||
projectedPicks,
|
||||
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks]);
|
||||
watchedParticipantIds,
|
||||
onToggleWatchlist: handleToggleWatchlist,
|
||||
showOnlyWatched,
|
||||
onShowOnlyWatchedChange: setShowOnlyWatched,
|
||||
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks, watchedParticipantIds, handleToggleWatchlist, showOnlyWatched, setShowOnlyWatched]);
|
||||
|
||||
const draftGridSectionProps = {
|
||||
draftSlots,
|
||||
|
|
@ -1383,27 +1455,44 @@ export default function DraftRoom() {
|
|||
</div>
|
||||
)}
|
||||
{mobileTab === "controls" && (
|
||||
<div className="h-full overflow-y-auto p-4 space-y-6">
|
||||
{isCommissioner && season.status === "pre_draft" && (
|
||||
<Button className="w-full min-h-[48px]" onClick={handleStartDraft}>
|
||||
Start Draft
|
||||
</Button>
|
||||
)}
|
||||
{isCommissioner && season.status === "draft" && !isDraftComplete && (
|
||||
isPaused ? (
|
||||
<Button className="w-full min-h-[48px]" onClick={handleResumeDraft}>
|
||||
Resume Draft
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isCommissioner && season.status === "pre_draft" && (
|
||||
<Button className="w-full min-h-[48px]" onClick={handleStartDraft}>
|
||||
Start Draft
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full min-h-[48px]" variant="outline" onClick={handlePauseDraft}>
|
||||
Pause Draft
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
)}
|
||||
|
||||
<Button variant="outline" asChild className="w-full min-h-[48px]">
|
||||
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
||||
</Button>
|
||||
{userTeam && (
|
||||
<AutodraftSettings
|
||||
seasonId={season.id}
|
||||
teamId={userTeam.id}
|
||||
isEnabled={userAutodraft.isEnabled}
|
||||
mode={userAutodraft.mode}
|
||||
queueOnly={userAutodraft.queueOnly}
|
||||
isMyTurn={isMyTurn}
|
||||
onUpdate={handleAutodraftUpdate}
|
||||
showOvernightNote={season.overnightPauseMode !== "none"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 p-4 border-t flex flex-col gap-3">
|
||||
{isCommissioner && season.status === "draft" && !isDraftComplete && (
|
||||
isPaused ? (
|
||||
<Button className="w-full min-h-[48px]" onClick={handleResumeDraft}>
|
||||
Resume Draft
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full min-h-[48px]" variant="outline" onClick={handlePauseDraft}>
|
||||
Pause Draft
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Button variant="outline" asChild className="w-full min-h-[48px]">
|
||||
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -277,6 +277,22 @@ export const draftQueue = pgTable("draft_queue", {
|
|||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const watchlist = pgTable("watchlist", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
seasonId: uuid("season_id")
|
||||
.notNull()
|
||||
.references(() => seasons.id, { onDelete: "cascade" }),
|
||||
teamId: uuid("team_id")
|
||||
.notNull()
|
||||
.references(() => teams.id, { onDelete: "cascade" }),
|
||||
participantId: uuid("participant_id")
|
||||
.notNull()
|
||||
.references(() => participants.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
uniqueWatch: uniqueIndex("watchlist_season_team_participant_unique").on(t.seasonId, t.teamId, t.participantId),
|
||||
}));
|
||||
|
||||
export const draftTimers = pgTable("draft_timers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
seasonId: uuid("season_id")
|
||||
|
|
@ -893,6 +909,21 @@ export const draftQueueRelations = relations(draftQueue, ({ one }) => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
export const watchlistRelations = relations(watchlist, ({ one }) => ({
|
||||
season: one(seasons, {
|
||||
fields: [watchlist.seasonId],
|
||||
references: [seasons.id],
|
||||
}),
|
||||
team: one(teams, {
|
||||
fields: [watchlist.teamId],
|
||||
references: [teams.id],
|
||||
}),
|
||||
participant: one(participants, {
|
||||
fields: [watchlist.participantId],
|
||||
references: [participants.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const autodraftSettingsRelations = relations(autodraftSettings, ({ one }) => ({
|
||||
season: one(seasons, {
|
||||
fields: [autodraftSettings.seasonId],
|
||||
|
|
|
|||
27
drizzle/0085_young_cobalt_man.sql
Normal file
27
drizzle/0085_young_cobalt_man.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TABLE IF NOT EXISTS "watchlist" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"season_id" uuid NOT NULL,
|
||||
"team_id" uuid NOT NULL,
|
||||
"participant_id" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "watchlist" ADD CONSTRAINT "watchlist_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "watchlist" ADD CONSTRAINT "watchlist_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "watchlist" ADD CONSTRAINT "watchlist_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "watchlist_season_team_participant_unique" ON "watchlist" USING btree ("season_id","team_id","participant_id");
|
||||
5215
drizzle/meta/0085_snapshot.json
Normal file
5215
drizzle/meta/0085_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -596,6 +596,13 @@
|
|||
"when": 1777477826949,
|
||||
"tag": "0084_wooden_retro_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 85,
|
||||
"version": "7",
|
||||
"when": 1777483407743,
|
||||
"tag": "0085_young_cobalt_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ interface ServerToClientEvents {
|
|||
"draft-resumed": (data: { seasonId: string; paused: boolean }) => void;
|
||||
"participant-removed-from-queues": (data: { participantId: string }) => void;
|
||||
"queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void;
|
||||
"watchlist-updated": (data: { participantIds: string[] }) => void;
|
||||
"draft-state-sync": (data: {
|
||||
currentPickNumber: number;
|
||||
isPaused: boolean;
|
||||
|
|
@ -62,6 +63,7 @@ interface ServerToClientEvents {
|
|||
participantId: string;
|
||||
queuePosition: number;
|
||||
}>;
|
||||
watchlistParticipantIds?: string[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +173,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
// tokens or flaky mobile networks. This socket-based sync provides an
|
||||
// additional, more reliable path since the socket is already connected.
|
||||
try {
|
||||
const [seasonData, picks, timerRows, queueItems] = await Promise.all([
|
||||
const [seasonData, picks, timerRows, queueItems, watchlistItems] = await Promise.all([
|
||||
db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, seasonId),
|
||||
}),
|
||||
|
|
@ -214,6 +216,14 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
orderBy: asc(schema.draftQueue.queuePosition),
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
teamId
|
||||
? db.query.watchlist.findMany({
|
||||
where: and(
|
||||
eq(schema.watchlist.teamId, teamId),
|
||||
eq(schema.watchlist.seasonId, seasonId)
|
||||
),
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (seasonData) {
|
||||
|
|
@ -227,6 +237,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
timeRemaining: t.timeRemaining,
|
||||
})),
|
||||
queue: teamId ? queueItems : undefined,
|
||||
watchlistParticipantIds: teamId ? watchlistItems.map((w) => w.participantId) : undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue