Mobile draft room improvements: watchlist, collapsible picks feed, autodraft controls (#355)

- 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:
Chris Parsons 2026-04-29 11:49:26 -07:00 committed by GitHub
parent 5268e07365
commit 380b0786ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 5881 additions and 100 deletions

View file

@ -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);
});
});
});

View 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");
});
});

View file

@ -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"
size="sm"
className="md:hidden shrink-0 h-9 gap-1.5"
onClick={() => setMobileFiltersOpen((prev) => !prev)}
aria-expanded={mobileFiltersOpen}
aria-label="Toggle filters"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
<SlidersHorizontal className="h-4 w-4" />
Filters
</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>
<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,6 +520,8 @@ 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"
: ""
}`}
>
@ -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,6 +626,8 @@ 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"
: "hover:bg-muted/50"
}`}
title={
@ -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"

View file

@ -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,8 +26,20 @@ 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>
<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>
) : (
@ -52,5 +66,7 @@ export const RecentPicksFeed = memo(function RecentPicksFeed({ picks }: { picks:
))
)}
</div>
)}
</div>
);
});

View file

@ -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,

View file

@ -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,7 +219,14 @@ 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);
@ -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]);

View file

@ -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
View 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;
}

View file

@ -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"),

View 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 });
}

View file

@ -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,12 +1455,29 @@ export default function DraftRoom() {
</div>
)}
{mobileTab === "controls" && (
<div className="h-full overflow-y-auto p-4 space-y-6">
<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>
)}
{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}>
@ -1400,11 +1489,11 @@ export default function DraftRoom() {
</Button>
)
)}
<Button variant="outline" asChild className="w-full min-h-[48px]">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
</div>
</div>
)}
</div>
) : (

View file

@ -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],

View 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");

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -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) {