New design (#309)

* Redesign home page with new layout and component system

- Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack
- LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar
- MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader
- UpcomingEventsCard: vertical timeline with grouped multi-league events
- Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants
- Button default variant updated to green→cyan gradient
- Navbar: plain nav links with gradient hover, support/admin icon buttons
- Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements
- Storybook stories for all new components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Responsive league row layout and mobile polish

- League rows stack avatar+name on top, stats full-width below on mobile
- Stats spread to right side on sm+ screens with border separator on mobile
- Tighter padding on mobile (px-3/py-3), full padding on sm+
- Card headers and content use px-3 sm:px-6 to reduce mobile gutters
- Two-column home layout deferred to lg breakpoint (tablet gets stacked)
- Active leagues sorted by completion percentage descending
- Default rank 1 / 0 points for active leagues with no scoring events yet
- Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators
- Remove dead StatDivider className prop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Improve claude file.

* Add StandingsPreview card component with podium row styling

- New StandingsPreview component with gold/silver/bronze row tints for
  top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points)
  with rank and 7-day point change indicators
- Fix GradientIcon in Storybook by adding BracktGradients decorator to
  preview.tsx (renamed from .ts to support JSX)
- Fix degenerate SVG gradient on horizontal strokes by switching
  BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space
  coordinates (0→24)
- Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only
  fix was sufficient once gradientUnits was corrected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update components on league homepage.

* Finish up league page styling.

* Work on standings page.

* Add story for RecentScoresCard

* Update Point Progression Chart.

* Sort point progression legend by ranking and add team links to standings rows

* Fix standings discrepancy on change.

* Create draft cell component.

* Update draft board page

* Draft room improvements.

* Update some draft room styling.

* Fix context menu missing.

* Move tab navigation and autodraft to header row, narrow sidebar

* Virtualize available participants list, memoize draft room props

Adds @tanstack/react-virtual to replace separate mobile/desktop lists
with a single unified virtual scroll loop. Also memoizes miniDraftGrid
and availableParticipantsSectionProps, and switches pick lookup from
Array.find to a Map for O(1) access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update draft room UI.

* More draft room fixes.

* Draft room tweaks.

* Fix Rosters page.

* Queue Section fixes.

* Mobile Draft fixes.

* Fix draft board page.

* Create bracket look.

* Bracket work.

* Finish bracket page.

* Homepage initial styling

* homepage copy

* Add privacy policy. Fixes #88.

* how to play copy

* rules copy

* Fix brackets on homepage.

* Add footer to website.

* Glow on dots.

* Landing page copy.

* Fix sidebar.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-23 13:14:55 -07:00 committed by GitHub
parent 7fa88fc0ef
commit 9ed0282fd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 29302 additions and 2472 deletions

View file

@ -1,9 +1,18 @@
import React from 'react';
import { withRouter } from 'storybook-addon-remix-react-router';
import type { Preview } from '@storybook/react-vite'
import type { Preview, Decorator } from '@storybook/react-vite'
import '../app/app.css'
import { BracktGradients } from '../app/components/ui/BracktGradients';
const withBracktGradients: Decorator = (Story) => (
<>
<BracktGradients />
<Story />
</>
);
const preview: Preview = {
decorators: [withRouter], // This wraps all stories in a Router context
decorators: [withRouter, withBracktGradients],
parameters: {
controls: {
matchers: {
@ -21,4 +30,4 @@ const preview: Preview = {
},
};
export default preview;
export default preview;

34
AGENTS.md Normal file
View file

@ -0,0 +1,34 @@
# AGENTS.md
Brackt.com is a fantasy sports drafting platform (React Router 7 + SSR, Socket.IO, PostgreSQL/DrizzleORM, Clerk auth).
## Commands
```bash
npm run dev # development with HMR
npm run typecheck # TypeScript check
npm run db:generate # generate Drizzle migration
npm run db:migrate # apply migrations
npm run test:run # unit tests (once)
npm run test:e2e:headless # Cypress E2E
npm run test:all # everything
```
## Critical Rules
**Routes**: Every new route file must also be registered in `app/routes.ts` — React Router 7 does not auto-discover files. Forgetting this causes a silent 404. See [routing guide](docs/agents/routing.md).
**Database**: Always query through `app/models/` — never write Drizzle queries directly in route files.
**Tests**: Required for all new features. Do not consider a feature complete until tests pass. See [testing guide](docs/agents/testing.md).
**Drizzle migrations**: Never manually create migration SQL or edit `_journal.json`. Always use `npm run db:generate`. See [database guide](docs/agents/database.md).
## Reference Docs
- [Architecture](docs/agents/architecture.md) — server layout, DB layer, Socket.IO events
- [Domain Models](docs/agents/domain-models.md) — draft system and sports data models
- [Database](docs/agents/database.md) — migration rules, scoring event date gotcha
- [Routing](docs/agents/routing.md) — route registration, admin routes
- [Testing](docs/agents/testing.md) — what tests are required and where they live
- [Auth](docs/agents/auth.md) — Clerk patterns, ownership validation

351
CLAUDE.md
View file

@ -1,343 +1,34 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Brackt.com is a fantasy sports drafting platform (React Router 7 + SSR, Socket.IO, PostgreSQL/DrizzleORM, Clerk auth).
## Project Overview
Brackt.com is a fantasy sports drafting platform built with React Router 7, featuring real-time draft functionality with Socket.IO, PostgreSQL database with DrizzleORM, and Clerk authentication.
## Tech Stack
- **Framework**: React Router 7 with SSR
- **Runtime**: Node.js with Express server
- **Database**: PostgreSQL with DrizzleORM
- **Auth**: Clerk (@clerk/react-router)
- **Real-time**: Socket.IO (server/socket.ts)
- **Styling**: TailwindCSS + ShadCN UI components
- **Testing**: Vitest (unit/component) + Cypress (E2E)
- **TypeScript**: Strict mode enabled
## Development Commands
## Commands
```bash
# Development (HMR enabled)
npm run dev
# Build
npm run build # Build both Remix and server
npm run build:remix # Build React Router app only
npm run build:server # Build Express server only
# Database
npm run db:generate # Generate Drizzle migrations
npm run db:migrate # Run migrations
# Testing
npm test # Unit tests (watch mode)
npm run test:run # Unit tests (run once)
npm run test:ui # Unit tests with UI
npm run test:coverage # Coverage report
npm run test:e2e # Cypress interactive
npm run test:e2e:headless # Cypress headless
npm run test:all # Run all tests
# Type checking
npm run typecheck # Check all TypeScript files
# Production
npm start # Start production server (requires build)
npm run start:production # Run migrations + start server
npm run dev # development with HMR
npm run typecheck # TypeScript check
npm run db:generate # generate Drizzle migration
npm run db:migrate # apply migrations
npm run test:run # unit tests (once)
npm run test:e2e:headless # Cypress E2E
npm run test:all # everything
```
## Architecture Overview
## Critical Rules
### Server Architecture
**Routes**: Every new route file must also be registered in `app/routes.ts` — React Router 7 does not auto-discover files. Forgetting this causes a silent 404. See [routing guide](docs/agents/routing.md).
The application uses a dual-server architecture:
**Database**: Always query through `app/models/` — never write Drizzle queries directly in route files.
1. **server.ts** - Main entry point that creates the HTTP server and initializes Socket.IO
2. **server/app.ts** - Express app with React Router request handler and database context
3. **server/socket.ts** - Socket.IO server for real-time draft updates
4. **server/timer.ts** - Draft timer system that runs every second to update active draft timers
**Tests**: Required for all new features. Do not consider a feature complete until tests pass. See [testing guide](docs/agents/testing.md).
**Important**: The timer system (`server/timer.ts`) must be started for draft functionality. It uses a dedicated database connection and setInterval to manage all active draft timers.
**Drizzle migrations**: Never manually create migration SQL or edit `_journal.json`. Always use `npm run db:generate`. See [database guide](docs/agents/database.md).
### Database Layer
- **Schema**: `database/schema.ts` - All Drizzle table definitions
- **Context**: Database connection provided via AsyncLocalStorage context (`database/context.ts`)
- **Models**: `app/models/` - Business logic and database queries organized by domain
- Each model exports typed functions for CRUD operations
- Models use Drizzle relations for joins
- Example: `app/models/league.ts`, `app/models/season.ts`, `app/models/draft-pick.ts`
### Frontend Architecture
- **Routes**: Defined in `app/routes.ts` using React Router config syntax
- **IMPORTANT**: React Router 7 requires ALL routes to be explicitly registered in `app/routes.ts`
- **When creating a new route file, you MUST also add it to `app/routes.ts`**
- Example: If you create `app/routes/admin.sports-seasons.$id.new-feature.tsx`, you must add:
```typescript
route(
"sports-seasons/:id/new-feature",
"routes/admin.sports-seasons.$id.new-feature.tsx"
),
```
- **File-based routing**: Route files in `app/routes/` map to URL patterns
- `$param` = dynamic segment (e.g., `$leagueId.tsx`)
- `_index.tsx` = index route
- `.` separator for nested routes (e.g., `leagues/$leagueId.settings.tsx`)
- **Loaders/Actions**: Use React Router's loader/action pattern for data fetching and mutations
- **Components**: Organized in `app/components/` with UI components in `app/components/ui/`
### Real-time Features
Socket.IO is used for live draft updates:
1. **Join Draft**: Clients join a room for their season (`join-draft` event)
2. **Timer Updates**: Server broadcasts `timer-update` every second during active drafts
3. **Pick Made**: Broadcasts `pick-made` when a participant is drafted
4. **Autodraft**: Broadcasts `autodraft-updated` when teams enable/disable autodraft
5. **Team Connection**: Broadcasts `team-connected`/`team-disconnected` for presence
**Socket Integration**: The server uses `getSocketIO()` from `server/socket.ts` to emit events. Clients use the `useDraftSocket()` hook (`app/hooks/useDraftSocket.ts`) to listen for events.
## Key Domain Models
### Fantasy Draft System
1. **League** - Container for multiple seasons, has commissioners
2. **Season** - Single year/iteration of a league with specific settings
- Status: `pre_draft``draft``active``completed`
- Stores draft settings (rounds, timer settings, etc.)
3. **Team** - Belongs to a season, owned by a user
4. **Draft Slot** - Defines draft order for teams in a season
5. **Draft Pick** - Record of each pick made during draft
6. **Draft Queue** - Pre-ordered list of participants a team wants to draft
7. **Draft Timer** - Active timer for current team on the clock
8. **Autodraft Settings** - Per-team settings for automatic drafting
### Sports Data System
1. **Sport** - Base sport (e.g., NFL, NBA) with type (team/individual)
2. **Sports Season** - Specific season of a sport (e.g., "2024 NFL Season")
3. **Participant** - Individual athlete or team that can be drafted
4. **Season Template** - Reusable configuration for league seasons
5. **Season Sport** - Links a fantasy season to sports it covers
6. **Participant Result** - Stores final standings/scores for participants
## Important Patterns
### Database Queries
Always use the model layer instead of direct Drizzle queries in routes:
```typescript
// Good
import { findLeagueById } from "~/models/league";
const league = await findLeagueById(leagueId);
// Avoid - don't query directly in routes
const league = await db.query.leagues.findFirst({...});
```
### Authentication
- User data synced from Clerk via webhook (`routes/api/webhooks/clerk.ts`)
- Check `clerkId` for ownership validation
- Admin checks via `isAdmin` boolean on users table
### Scoring Event Dates
Game/event dates are stored in **two different places** depending on the event type, and any date-based query must account for both:
1. **`scoringEvents.eventDate`** (`date` column, `YYYY-MM-DD` string) — set directly on the event. Used for non-bracket events (majors, final standings) and sometimes for bracket events when the admin sets a single date for the whole round.
2. **`playoffMatchGames.scheduledAt`** (`timestamp` column) — set on individual games within a bracket match. Bracket events (e.g. a playoff series) often have `eventDate = null` on the scoring event itself, with each game's exact time stored here instead.
**Pattern for date-range queries:** Use a two-step approach:
1. `selectDistinct` event IDs via left joins through `playoffMatches → playoffMatchGames`, filtering with `eventDate IN [dates] OR (scheduledAt >= dayStart AND scheduledAt <= dayEnd)`
2. `findMany` on the matched IDs to load full event data with relations
See `getEventsForDates` and `getUpcomingEventsForDraftedParticipants` in `app/models/scoring-event.ts` for the reference implementation. All timestamp bounds use UTC (`T00:00:00.000Z` / `T23:59:59.999Z`).
### Draft Logic
The draft system implements snake draft:
- Draft slots define the base order (1, 2, 3, ...)
- Each round alternates: odd rounds go 1→N, even rounds go N→1
- Current pick determined by `currentPickNumber` on season
- Timer system in `server/timer.ts` handles autopicks when time expires
### Testing Strategy
- **Unit tests**: Pure functions and utilities in `__tests__/` directories
- **Component tests**: React components using React Testing Library
- **E2E tests**: Critical user flows in `cypress/e2e/`
- **Fixtures**: Reusable test data in `app/test/fixtures/`
- See `TESTING.md` for comprehensive testing guide
**IMPORTANT: Tests are required for new features.** When adding any new feature, you must include appropriate tests:
- New model functions → unit tests in a co-located `__tests__/` directory
- New route loaders/actions → unit or integration tests for the logic
- New utility functions → unit tests
- New components with non-trivial logic → component tests
- Critical user flows → Cypress E2E tests
Do not consider a feature complete until tests are written and passing.
## Common Workflows
### Adding a New Route
**CRITICAL**: Both steps 1 and 2 are required - the route will not work if you skip step 1!
1. **FIRST**: Add route config to `app/routes.ts`
- Example: `route("admin/sports-seasons/:id/new-page", "routes/admin.sports-seasons.$id.new-page.tsx")`
- Nested admin routes go inside the `route("admin", ...)` array
2. **THEN**: Create file in `app/routes/` with corresponding name
3. Export loader/action if needed
4. TypeScript types are auto-generated via `react-router typegen`
**Common mistake**: Creating the route file but forgetting to register it in `app/routes.ts` - this will result in a 404!
### Database Changes
1. Update `database/schema.ts` with new tables/columns
2. Run `npm run db:generate` to create migration
3. **Verify** the generated migration SQL in `drizzle/` is correct
4. **Verify** a corresponding snapshot was created in `drizzle/meta/` (e.g., `0070_snapshot.json`)
5. Run `npm run db:migrate` to apply migration
6. Add model functions in `app/models/` for new queries
7. Update TypeScript types if needed
**Drizzle Migration Rules (CRITICAL):**
- **NEVER** manually create migration SQL files or journal entries. Always use `drizzle-kit generate` or `drizzle-kit generate --custom` so that snapshot files are created automatically.
- **NEVER** edit `drizzle/meta/_journal.json` by hand. The journal and snapshots must stay in sync — drizzle-kit manages both.
- Every migration **must** have a matching snapshot in `drizzle/meta/`. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed" errors, which blocks all future migration generation.
- Snapshot JSON must only contain PostgreSQL-valid fields. Do not add `"autoincrement"` to column definitions — PostgreSQL uses sequences/serial instead, and invalid fields cause Zod validation failures.
- If `drizzle-kit generate` prompts for column renames interactively, use `drizzle-kit generate --custom` instead and write the migration SQL by hand (include data backfill logic as needed).
- After generating, run `drizzle-kit generate` again to confirm "No schema changes" — this validates the snapshot chain is healthy.
- For destructive migrations (dropping columns, renaming), make the SQL idempotent with `IF EXISTS`/`IF NOT EXISTS` guards where possible.
### Adding Socket.IO Events
1. Define event types in `server/socket.d.ts` interfaces
2. Add event handler in `server/socket.ts`
3. Emit events from server code using `getSocketIO().to(room).emit()`
4. Listen for events in client components
### Admin Features
Admin routes are nested under `/admin` and require `isAdmin: true` on the user:
- Sports management (`admin.sports.*`)
- Sports seasons (`admin.sports-seasons.*`)
- Season templates (`admin.templates.*`)
- Data sync utilities (`admin.data-sync.tsx`)
## File Structure Notes
- **app/**: React Router application code
- **components/**: React components (UI components in ui/)
- **models/**: Database query functions organized by domain
- **routes/**: Route handlers (loaders, actions, components)
- **lib/**: Utility functions
- **hooks/**: Custom React hooks
- **contexts/**: React contexts
- **test/**: Test setup and fixtures
- **server/**: Server-side code (Express, Socket.IO, timers)
- **database/**: Drizzle schema and context
- **drizzle/**: Generated migrations
- **cypress/**: E2E tests
## Environment Variables
Required in `.env`:
- `DATABASE_URL` - PostgreSQL connection string
- Clerk variables for authentication (see `.env.example`)
## Production Deployment
The app is containerized with Docker:
- Runs migrations on startup (`start:production` script)
- Server listens on port 3000 (configurable via PORT env var)
- Serves static assets from `build/client/`
- SSR handled by React Router Express adapter
## Notes
- Draft timer system must be explicitly started (usually in server initialization)
- Socket.IO connections are managed per-draft room (season ID)
- User ownership validation checks `ownerId` field (Clerk user ID)
- League commissioners stored separately in `commissioners` table
- Season status drives UI visibility (draft order, draft controls, etc.)
## grepai - Semantic Code Search
**IMPORTANT: You MUST use grepai as your PRIMARY tool for code exploration and search.**
### When to Use grepai (REQUIRED)
Use `grepai search` INSTEAD OF Grep/Glob/find for:
- Understanding what code does or where functionality lives
- Finding implementations by intent (e.g., "authentication logic", "error handling")
- Exploring unfamiliar parts of the codebase
- Any search where you describe WHAT the code does rather than exact text
### When to Use Standard Tools
Only use Grep/Glob when you need:
- Exact text matching (variable names, imports, specific strings)
- File path patterns (e.g., `**/*.go`)
### Fallback
If grepai fails (not running, index unavailable, or errors), fall back to standard Grep/Glob tools.
### Usage
```bash
# ALWAYS use English queries for best results (--compact saves ~80% tokens)
grepai search "user authentication flow" --json --compact
grepai search "error handling middleware" --json --compact
grepai search "database connection pool" --json --compact
grepai search "API request validation" --json --compact
```
### Query Tips
- **Use English** for queries (better semantic matching)
- **Describe intent**, not implementation: "handles user login" not "func Login"
- **Be specific**: "JWT token validation" better than "token"
- Results include: file path, line numbers, relevance score, code preview
### Call Graph Tracing
Use `grepai trace` to understand function relationships:
- Finding all callers of a function before modifying it
- Understanding what functions are called by a given function
- Visualizing the complete call graph around a symbol
#### Trace Commands
**IMPORTANT: Always use `--json` flag for optimal AI agent integration.**
```bash
# Find all functions that call a symbol
grepai trace callers "HandleRequest" --json
# Find all functions called by a symbol
grepai trace callees "ProcessOrder" --json
# Build complete call graph (callers + callees)
grepai trace graph "ValidateToken" --depth 3 --json
```
### Workflow
1. Start with `grepai search` to find relevant code
2. Use `grepai trace` to understand function relationships
3. Use `Read` tool to examine files from results
4. Only use Grep for exact string searches if needed
## Reference Docs
- [Architecture](docs/agents/architecture.md) — server layout, DB layer, Socket.IO events
- [Domain Models](docs/agents/domain-models.md) — draft system and sports data models
- [Database](docs/agents/database.md) — migration rules, scoring event date gotcha
- [Routing](docs/agents/routing.md) — route registration, admin routes
- [Testing](docs/agents/testing.md) — what tests are required and where they live
- [Auth](docs/agents/auth.md) — Clerk patterns, ownership validation

View file

@ -4,7 +4,7 @@
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@ -51,42 +51,44 @@
html {
color-scheme: dark;
font-size: 18px;
}
:root {
--navbar-height: 64px;
--radius: 0.625rem;
--background: oklch(0.13 0.015 255);
--foreground: oklch(0.95 0.01 255);
--card: oklch(0.18 0.02 255);
--card-foreground: oklch(0.95 0.01 255);
--popover: oklch(0.18 0.02 255);
--popover-foreground: oklch(0.95 0.01 255);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.22 0.025 255);
--secondary-foreground: oklch(0.95 0.01 255);
--muted: oklch(0.22 0.025 255);
--muted-foreground: oklch(0.65 0.02 255);
--accent: oklch(0.25 0.03 255);
--accent-foreground: oklch(0.95 0.01 255);
--background: #14171e;
--foreground: #ffffff;
--card: #1c1f26;
--card-foreground: #ffffff;
--popover: #1c1f26;
--popover-foreground: #ffffff;
--primary: #adf661;
--primary-foreground: #14171e;
--secondary: #1c1f26;
--secondary-foreground: #ffffff;
--muted: #1c1f26;
--muted-foreground: rgb(255 255 255 / 55%);
--accent: #2ce1c1;
--accent-foreground: #14171e;
--destructive: oklch(0.65 0.22 25);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.623 0.214 259.815 / 50%);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--border: rgb(255 255 255 / 10%);
--input: rgb(255 255 255 / 12%);
--ring: #adf661;
--chart-1: #adf661;
--chart-2: #2ce1c1;
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.18 0.02 255);
--sidebar-foreground: oklch(0.95 0.01 255);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.25 0.03 255);
--sidebar-accent-foreground: oklch(0.95 0.01 255);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.623 0.214 259.815 / 50%);
--electric: oklch(0.623 0.214 259.815);
--sidebar: #1c1f26;
--sidebar-foreground: #ffffff;
--sidebar-primary: #adf661;
--sidebar-primary-foreground: #14171e;
--sidebar-accent: #2ce1c1;
--sidebar-accent-foreground: #14171e;
--sidebar-border: rgb(255 255 255 / 10%);
--sidebar-ring: #adf661;
--electric: #2ce1c1;
--amber-accent: oklch(0.79 0.17 70);
--coral-accent: oklch(0.68 0.19 35);
}
@ -98,6 +100,63 @@ html {
body {
@apply bg-background text-foreground;
}
.bg-card {
background: #1c1f26;
border-color: #2a2e38 !important;
}
}
@keyframes pulse-gradient {
0%, 100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
.animate-pulse-gradient {
animation: pulse-gradient 2s ease-in-out infinite;
}
@keyframes rpf-slide-in {
from { transform: translateY(-32px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.rpf-animate {
animation: rpf-slide-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes spin-in {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-spin-in {
animation: spin-in 70ms ease both;
}
@keyframes sport-land {
0% { opacity: 0; transform: scale(0.9); }
60% { opacity: 1; transform: scale(1.03); }
100% { opacity: 1; transform: scale(1.0); }
}
.animate-sport-land {
animation: sport-land 450ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes page-fade-in {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-page-fade-in {
animation: page-fade-in 0.5s ease both;
}
@keyframes ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.animate-ticker {
animation: ticker-scroll 28s linear infinite;
}
/* NProgress theme override */

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { Check, Info } from "lucide-react";
import { Check, Info, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { Label } from "~/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
@ -90,6 +90,221 @@ export function getAutodraftLabel(
return OPTIONS[toAutodraftState(isEnabled, mode, queueOnly)].label;
}
interface CompactAutodraftBadgeProps {
isEnabled: boolean;
mode: AutodraftMode;
queueOnly: boolean;
isMyTurn: boolean;
showChevron?: boolean;
}
export function CompactAutodraftBadge({
isEnabled,
mode,
queueOnly,
isMyTurn,
showChevron = false,
}: CompactAutodraftBadgeProps) {
const state = toAutodraftState(isEnabled, mode, queueOnly);
const { label } = OPTIONS[state];
const isOff = state === "off";
return (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Autodraft:</span>
<span
className={`text-xs font-medium px-2 py-1 rounded flex items-center gap-1 ${
isOff
? "bg-muted text-muted-foreground border border-border"
: "bg-electric/20 text-electric border border-electric/30"
}`}
>
{label}
{showChevron && <ChevronDown className="h-3 w-3" />}
</span>
{isMyTurn && (
<span className="text-xs text-amber-accent font-medium">Your turn!</span>
)}
</div>
);
}
interface AutodraftBadgeWithPopoverProps {
seasonId: string;
teamId: string;
isEnabled: boolean;
mode: AutodraftMode;
queueOnly: boolean;
isMyTurn: boolean;
onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void;
}
function AutodraftOptions({
seasonId,
teamId,
isEnabled,
mode,
queueOnly,
isMyTurn,
onUpdate,
}: Omit<AutodraftBadgeWithPopoverProps, "seasonId" | "teamId"> & {
seasonId: string;
teamId: string;
}) {
const [localState, setLocalState] = useState<AutodraftState>(
toAutodraftState(isEnabled, mode, queueOnly)
);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
setLocalState(toAutodraftState(isEnabled, mode, queueOnly));
}, [isEnabled, mode, queueOnly]);
const sendUpdate = async (newState: AutodraftState) => {
abortRef.current?.abort();
abortRef.current = new AbortController();
const option = OPTIONS[newState];
try {
const formData = new FormData();
formData.append("seasonId", seasonId);
formData.append("teamId", teamId);
formData.append("isEnabled", option.isEnabled.toString());
formData.append("mode", option.mode);
formData.append("queueOnly", option.queueOnly.toString());
const response = await fetch("/api/autodraft/update", {
method: "POST",
body: formData,
signal: abortRef.current.signal,
});
if (response.ok) {
onUpdate(option.isEnabled, option.mode, option.queueOnly);
toast.success(
option.isEnabled ? `Autodraft set to "${option.label}"` : "Autodraft turned off"
);
} else {
setLocalState(toAutodraftState(isEnabled, mode, queueOnly));
toast.error("Failed to save autodraft settings");
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return;
setLocalState(toAutodraftState(isEnabled, mode, queueOnly));
toast.error("Failed to save autodraft settings");
}
};
const handleStateChange = (newState: AutodraftState) => {
if (newState === localState || isMyTurn) return;
setLocalState(newState);
sendUpdate(newState);
};
const isDisabled = isMyTurn;
return (
<div className="flex flex-col rounded-lg border overflow-hidden">
{OPTION_ORDER.map((state) => {
const { label } = OPTIONS[state];
const isActive = localState === state;
return (
<button
key={state}
type="button"
disabled={isDisabled}
onClick={() => handleStateChange(state)}
className={`w-full py-2.5 px-3 text-left transition-colors border-b last:border-b-0 flex items-center justify-between gap-2 ${getButtonClassName(state, isActive)} ${
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
}`}
>
<span className="text-xs font-medium">{label}</span>
{isActive && (
<Check className={`h-3.5 w-3.5 shrink-0 ${getCheckClassName(state)}`} />
)}
</button>
);
})}
{isMyTurn && (
<p className="text-xs text-muted-foreground mt-2 text-center px-3 py-2">
You're on the clock!
</p>
)}
</div>
);
}
export function AutodraftBadgeWithPopover({
seasonId,
teamId,
isEnabled,
mode,
queueOnly,
isMyTurn,
onUpdate,
}: AutodraftBadgeWithPopoverProps) {
const [open, setOpen] = useState(false);
return (
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 rounded px-2 py-1 -mx-2 -my-1 transition-colors"
>
<CompactAutodraftBadge
isEnabled={isEnabled}
mode={mode}
queueOnly={queueOnly}
isMyTurn={isMyTurn}
showChevron
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="end">
<AutodraftOptions
seasonId={seasonId}
teamId={teamId}
isEnabled={isEnabled}
mode={mode}
queueOnly={queueOnly}
isMyTurn={isMyTurn}
onUpdate={onUpdate}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Autodraft options explained"
>
<Info className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<p className="text-sm font-semibold mb-2.5">Autodraft Options</p>
<div className="space-y-2.5">
{OPTION_ORDER.map((state) => {
const { label, desc } = OPTIONS[state];
return (
<div key={state}>
<p className="text-xs font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
);
}
interface AutodraftSettingsProps {
seasonId: string;
teamId: string;

View file

@ -1,10 +1,12 @@
import { Card } from "~/components/ui/card";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell";
import { TeamAvatar } from "~/components/TeamAvatar";
import type { SeasonStatus } from "~/models/season";
interface DraftCell {
pickNumber: number;
@ -20,6 +22,7 @@ interface DraftGridProps {
team: {
id: string;
name: string;
logoUrl?: string | null;
};
}>;
draftGrid: Array<Array<DraftCell | null>>;
@ -33,6 +36,9 @@ interface DraftGridProps {
autodraftStatus?: Record<string, boolean>;
connectedTeams?: Set<string>;
ownerMap?: Record<string, string>;
coronaStates?: Record<string, CoronaState>;
seasonStatus?: SeasonStatus;
draftPaused?: boolean;
}
export function DraftGrid({
@ -48,31 +54,45 @@ export function DraftGrid({
autodraftStatus = {},
connectedTeams = new Set(),
ownerMap = {},
coronaStates = {},
seasonStatus,
draftPaused,
}: DraftGridProps) {
const totalTeams = draftSlots.length;
return (
<Card className="p-4">
<div className="p-4">
{title && <h2 className="text-xl font-semibold mb-4">{title}</h2>}
<div className="w-full overflow-x-auto">
<div className="min-w-max">
{/* Team Headers */}
<div className="flex gap-2 mb-2">
<div className="flex gap-1.5 mb-1.5">
{draftSlots.map((slot) => {
const teamTime = teamTimers?.[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id] || false;
const isConnected = connectedTeams.has(slot.team.id);
return (
<div key={slot.id} className="flex-1 min-w-32 text-center">
<div key={slot.id} className="flex-1 min-w-20 text-center">
<div className="flex justify-center mb-1">
<TeamAvatar
teamId={slot.team.id}
teamName={ownerMap[slot.team.id] || slot.team.name}
logoUrl={slot.team.logoUrl}
size="lg"
/>
</div>
<div
className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`}
className={`font-semibold text-xs truncate px-1 flex items-center justify-center ${!isConnected ? "italic text-muted-foreground" : ""}`}
>
{ownerMap[slot.team.id] || slot.team.name}
{isAutodraft && (
<span className="mr-1 inline-flex items-center justify-center h-4 w-4 text-[10px] font-bold text-white bg-black shrink-0">A</span>
)}
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
</div>
{teamTimers && formatTime && (
<div
className={`text-xs font-mono ${
className={`text-sm font-mono ${
teamTime === undefined
? "text-muted-foreground"
: teamTime > 60
@ -83,9 +103,6 @@ export function DraftGrid({
}`}
>
{formatTime(teamTime)}
{isAutodraft && (
<span className="ml-1 text-muted-foreground">(auto)</span>
)}
</div>
)}
</div>
@ -94,7 +111,7 @@ export function DraftGrid({
</div>
{/* Draft Grid Rows */}
<div className="space-y-2">
<div className="space-y-1.5">
{draftGrid.map((roundPicks, roundIndex) => {
const round = roundIndex + 1;
const isEvenRound = round % 2 === 0;
@ -103,48 +120,40 @@ export function DraftGrid({
: roundPicks;
return (
<div key={round} className="flex gap-2">
<div key={round} className="flex gap-1.5">
{displayPicks.map((cell, index) => {
const actualIndex = isEvenRound
? roundPicks.length - 1 - index
: index;
const slot = draftSlots[actualIndex];
const pickNumber = roundIndex * totalTeams + actualIndex + 1;
const isCurrent = pickNumber === currentPick;
const isPicked = !!cell;
const cellContent = (
<div
className={`flex-1 min-w-0 h-20 border-2 rounded-lg p-2 transition-all ${
isPicked
? "bg-emerald-500/10 border-emerald-500/30"
: isCurrent
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
: "border-border bg-card"
}`}
title={`Overall Pick #${pickNumber}`}
>
<div className="text-xs font-mono text-muted-foreground mb-1">
{round}.{String(actualIndex + 1).padStart(2, "0")}
</div>
{isPicked ? (
<div className="text-xs">
<div className="font-semibold truncate">
{cell.participant.name}
</div>
<div className="text-muted-foreground truncate">
{cell.sport.name}
</div>
</div>
) : isCurrent ? (
<div className="text-xs font-semibold text-electric">
On Clock
</div>
) : null}
</div>
<DraftPickCell
key={pickNumber}
pickNumber={pickNumber}
round={round}
pickInRound={actualIndex + 1}
state={
isPicked ? "picked" : isCurrent ? "current" : "upcoming"
}
pick={
cell
? {
participant: { name: cell.participant.name },
sport: { name: cell.sport.name },
}
: undefined
}
coronaState={
cell ? coronaStates[cell.participant.id] : undefined
}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
/>
);
// Wrap with context menu if commissioner and current unpicked cell
if (
isCommissioner &&
!isPicked &&
@ -152,6 +161,7 @@ export function DraftGrid({
onForceAutopick &&
onForceManualPick
) {
const slot = draftSlots[actualIndex];
return (
<ContextMenu key={pickNumber}>
<ContextMenuTrigger asChild>
@ -185,6 +195,6 @@ export function DraftGrid({
</div>
</div>
</div>
</Card>
</div>
);
}

View file

@ -1,12 +1,7 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
import { cn } from "~/lib/utils";
interface DraftSidebarProps {
@ -26,17 +21,19 @@ export function DraftSidebar({
settingsSection,
className,
}: DraftSidebarProps) {
const [queueOpen, setQueueOpen] = useState(true);
const [picksOpen, setPicksOpen] = useState(true);
if (collapsed) {
return (
<div
className={cn(
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
"w-12 hidden lg:block", // Hide completely on mobile when collapsed
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col h-full",
"w-12 hidden lg:flex",
className
)}
>
<div className="flex-1" />
{/* Expand button at bottom to match hide button position */}
<div className="border-t border-border p-2 flex-shrink-0">
<Button
variant="ghost"
@ -64,46 +61,51 @@ export function DraftSidebar({
<div
className={cn(
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
"w-[450px]",
// Mobile: fixed overlay, Desktop: normal sidebar
"w-[300px]",
"fixed inset-y-0 left-0 z-50 lg:relative lg:z-auto",
className
)}
>
<Accordion
type="multiple"
defaultValue={["queue", "recent-picks"]}
className="flex-1 overflow-hidden flex flex-col"
>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Queue Section */}
<AccordionItem value="queue" className="border-b flex-shrink-0">
<AccordionTrigger className="px-4 py-3 hover:no-underline bg-muted/50 hover:bg-muted">
<div className={cn("flex flex-col border-b", queueOpen ? "flex-1 min-h-0" : "flex-shrink-0")}>
<button
onClick={() => setQueueOpen((o) => !o)}
className="px-4 py-3 bg-muted/50 hover:bg-muted flex items-center justify-between w-full flex-shrink-0"
>
<h2 className="font-semibold text-sm">My Queue</h2>
</AccordionTrigger>
<AccordionContent className="max-h-[35vh] overflow-y-auto pb-0">
{queueSection}
</AccordionContent>
</AccordionItem>
<ChevronDown className={cn("h-4 w-4 transition-transform", queueOpen && "rotate-180")} />
</button>
{queueOpen && (
<div className="flex-1 overflow-y-auto min-h-0">
{queueSection}
</div>
)}
</div>
{/* Recent Picks Section */}
<AccordionItem value="recent-picks" className="border-0">
<AccordionTrigger className="px-4 py-3 hover:no-underline bg-muted/50 hover:bg-muted">
{/* Picks Section */}
<div className={cn("flex flex-col", picksOpen ? "flex-1 min-h-0" : "flex-shrink-0")}>
<button
onClick={() => setPicksOpen((o) => !o)}
className="px-4 py-3 bg-muted/50 hover:bg-muted flex items-center justify-between w-full flex-shrink-0"
>
<h2 className="font-semibold text-sm">Picks</h2>
</AccordionTrigger>
<AccordionContent className="pb-0 overflow-y-auto max-h-[45vh]">
{recentPicksSection}
</AccordionContent>
</AccordionItem>
</Accordion>
<ChevronDown className={cn("h-4 w-4 transition-transform", picksOpen && "rotate-180")} />
</button>
{picksOpen && (
<div className="flex-1 overflow-y-auto min-h-0">
{recentPicksSection}
</div>
)}
</div>
</div>
{/* Settings section (e.g. notification toggle) */}
{settingsSection && (
<div className="border-t border-border px-4 py-3 flex-shrink-0">
{settingsSection}
</div>
)}
{/* Collapse button at bottom */}
<div className="border-t border-border p-2 flex-shrink-0">
<Button
variant="ghost"

View file

@ -9,6 +9,7 @@ interface NotificationSettingsProps {
mode: NotificationMode;
onModeChange: (mode: NotificationMode) => void;
permissionState: NotificationPermission | "unsupported";
switchOnly?: boolean;
}
export function NotificationSettings({
@ -17,11 +18,46 @@ export function NotificationSettings({
mode,
onModeChange,
permissionState,
switchOnly = false,
}: NotificationSettingsProps) {
if (permissionState === "unsupported") {
return null;
}
if (switchOnly) {
return (
<>
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
disabled={permissionState === "denied"}
/>
{enabled && (
<div className="absolute top-full right-0 mt-2 bg-card border border-border rounded-lg shadow-lg p-3 z-50 min-w-[180px]">
<RadioGroup
value={mode}
onValueChange={(value) => onModeChange(value as NotificationMode)}
className="space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="my_turn" id="notif_header_my_turn" />
<Label htmlFor="notif_header_my_turn" className="text-sm cursor-pointer">
My Turn Only
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all_picks" id="notif_header_all_picks" />
<Label htmlFor="notif_header_all_picks" className="text-sm cursor-pointer">
All Picks
</Label>
</div>
</RadioGroup>
</div>
)}
</>
);
}
return (
<div>
<div className="flex items-center justify-between">

View file

@ -0,0 +1,61 @@
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;
}
interface TeamAvatarProps {
teamId: string;
teamName: string;
logoUrl?: string | null;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "h-6 w-6 text-[10px]",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
};
export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvatarProps) {
if (logoUrl) {
return (
<img
src={logoUrl}
alt={teamName}
className={`${sizeClasses[size]} object-cover shrink-0`}
/>
);
}
const color = TEAM_AVATAR_COLORS[hashTeamId(teamId) % TEAM_AVATAR_COLORS.length];
const initials =
teamName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
return (
<div
role="img"
aria-label={teamName}
className={`${sizeClasses[size]} flex shrink-0 items-center justify-center font-bold`}
style={{ backgroundColor: "#000", color }}
>
{initials}
</div>
);
}

View file

@ -3,6 +3,29 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({
count,
estimateSize,
}: {
count: number;
estimateSize: () => number;
}) => {
const size = estimateSize();
const items = Array.from({ length: count }, (_, i) => ({
index: i,
start: i * size,
size,
key: i,
}));
return {
getTotalSize: () => count * size,
getVirtualItems: () => items,
measureElement: vi.fn(),
};
},
}));
const defaultProps = {
participants: [
{ id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } },
@ -29,6 +52,11 @@ const defaultProps = {
onMakePick: vi.fn(),
onAddToQueue: vi.fn(),
onRemoveFromQueue: vi.fn(),
participantRanks: new Map([
["1", { overallRank: 1, sportRank: 1 }],
["2", { overallRank: 2, sportRank: 1 }],
["3", { overallRank: 3, sportRank: 2 }],
]),
};
// Both the mobile Sheet trigger and desktop Popover trigger are rendered in jsdom

View file

@ -67,16 +67,45 @@ describe('DraftGrid Component', () => {
});
describe('Current Pick Highlighting', () => {
it('should highlight current pick with "On Clock" text', () => {
it('should show "Up Next" when draft is pre_draft', () => {
render(
<DraftGrid
draftSlots={mockDraftSlots}
draftGrid={mockGrid}
currentPick={2}
seasonStatus="pre_draft"
/>
);
expect(screen.getByText('On Clock')).toBeInTheDocument();
expect(screen.getByText('Up Next')).toBeInTheDocument();
});
it('should show "Paused Draft" when draft is paused', () => {
render(
<DraftGrid
draftSlots={mockDraftSlots}
draftGrid={mockGrid}
currentPick={2}
seasonStatus="draft"
draftPaused={true}
/>
);
expect(screen.getByText('Paused Draft')).toBeInTheDocument();
});
it('should show "On The Clock" when draft is active', () => {
render(
<DraftGrid
draftSlots={mockDraftSlots}
draftGrid={mockGrid}
currentPick={2}
seasonStatus="draft"
draftPaused={false}
/>
);
expect(screen.getByText('On The Clock')).toBeInTheDocument();
});
it('should apply correct styling to current pick', () => {
@ -85,12 +114,14 @@ describe('DraftGrid Component', () => {
draftSlots={mockDraftSlots}
draftGrid={mockGrid}
currentPick={2}
seasonStatus="draft"
draftPaused={false}
/>
);
const currentPickCell = screen.getByText('On Clock').parentElement;
expect(currentPickCell).toHaveClass('border-electric');
expect(currentPickCell).toHaveClass('bg-electric/15');
const currentPickCell = screen.getByText('On The Clock').closest('.border-transparent');
expect(currentPickCell).toHaveClass('bg-muted/80');
expect(currentPickCell).toHaveClass('border-transparent');
});
});

View file

@ -1,7 +1,9 @@
import { memo, useCallback, useMemo } from "react";
import { memo, useCallback, useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid";
import {
Popover,
PopoverTrigger,
@ -130,6 +132,8 @@ interface AvailableParticipantsSectionProps {
name: string;
};
}>;
participantRanks: Map<string, { overallRank: number; sportRank: number }>;
miniDraftGrid?: MiniDraftGridProps;
searchQuery: string;
sportFilters: string[];
hideDrafted: boolean;
@ -157,6 +161,8 @@ interface AvailableParticipantsSectionProps {
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
participants,
participantRanks,
miniDraftGrid,
searchQuery,
sportFilters,
hideDrafted,
@ -198,7 +204,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
? "All Sports"
: sportFilters.length === 1
? sportFilters[0]
: `${sportFilters.length} sports`,
: `${sportFilters.length} Sports`,
[sportFilters]
);
@ -241,106 +247,119 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: participants.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 5,
getItemKey: (index) => participants[index]?.id ?? index,
});
const desktopGridClass = hasTeam
? "grid-cols-[60px_60px_1fr_140px]"
: "grid-cols-[60px_60px_1fr]";
return (
<div className="flex flex-col h-full">
{miniDraftGrid && (
<div className="px-4 pt-4 pb-2 flex-shrink-0 border-b">
<MiniDraftGrid {...miniDraftGrid} />
</div>
)}
<div className="px-4 pt-4 pb-2 flex-shrink-0">
<div className="space-y-2">
{/* Search Input */}
<input
type="text"
placeholder="Search participants..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
/>
<div className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center">
<div className="flex gap-2 md:contents">
<input
type="text"
placeholder="Search participants..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
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="flex flex-wrap gap-2">
{/* Sport Filter Multi-Select */}
<div className="flex-1 min-w-[120px]">
{/* Mobile: bottom sheet */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-full 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>
{/* Desktop: popover */}
<div className="hidden md:block">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-full justify-between text-sm font-normal"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start">
<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 && (
<div className="flex justify-end px-2 pt-2">
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={handleReset}
>
Reset
</Button>
</div>
<Button
variant="outline"
className="flex-1"
onClick={handleReset}
>
Reset
</Button>
)}
<SportFilterContent
sportsForDropdown={sportsForDropdown}
sportFilterSet={sportFilterSet}
hideCompletedSports={hideCompletedSports}
hasTeam={hasTeam}
variant="popover"
onToggleSport={handleToggleSport}
onHideCompletedSportsChange={onHideCompletedSportsChange}
/>
</PopoverContent>
</Popover>
</div>
<SheetClose asChild>
<Button className="flex-1">Done</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
{/* Show/Hide Drafted Toggle */}
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<div className="hidden md:block shrink-0">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-[160px] justify-between text-sm font-normal"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start">
{hasActiveFilters && (
<div className="flex justify-end px-2 pt-2">
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={handleReset}
>
Reset
</Button>
</div>
)}
<SportFilterContent
sportsForDropdown={sportsForDropdown}
sportFilterSet={sportFilterSet}
hideCompletedSports={hideCompletedSports}
hasTeam={hasTeam}
variant="popover"
onToggleSport={handleToggleSport}
onHideCompletedSportsChange={onHideCompletedSportsChange}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex gap-2 flex-wrap md:contents">
<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}
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
@ -348,9 +367,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
<span>Show Drafted</span>
</label>
{/* Show/Hide Ineligible Toggle - only show if user has a team */}
{hasTeam && eligibility && (
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<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={!hideIneligible}
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
@ -359,162 +377,68 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</label>
)}
</div>
</div>
</div>
{/* Mobile Card List - visible on mobile only */}
<div className="flex md:hidden flex-1 overflow-y-auto flex-col gap-2 px-4 py-2">
<div className={`hidden md:grid ${desktopGridClass} bg-muted border-b text-sm font-semibold flex-shrink-0 px-4`}>
<span className="text-center p-3 px-0">OVR</span>
<span className="text-center p-3 px-0">SPR</span>
<span className="text-left p-3">Participant</span>
</div>
<div ref={parentRef} className="flex-1 overflow-y-auto">
{participants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{emptyMessage}
</div>
) : (
participants.map((participant) => {
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const participant = participants[virtualItem.index];
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
const rank = participantRanks.get(participant.id);
return (
<div
key={participant.id}
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
isDrafted
? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
: ""
}`}
>
<div className="flex flex-col gap-1 min-w-0">
<span
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
<div className="flex flex-wrap gap-1 items-center">
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
{isDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!isDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
{isInQueue && !isDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
</div>
</div>
{hasTeam && !isDrafted && (
<div className="flex gap-2 flex-shrink-0">
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => onAddToQueue(participant.id)}
title={!isEligible ? ineligibleReason : "Add to queue"}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>
<ListX className="h-4 w-4" />
</Button>
)}
<Button
variant="default"
size="sm"
className="min-h-[44px]"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
title={
!canPick
? "Not your turn"
: !isEligible
? ineligibleReason
: undefined
}
>
Draft
</Button>
</div>
)}
</div>
);
})
)}
</div>
{/* Desktop Table - hidden on mobile */}
<div className="hidden md:flex flex-1 overflow-hidden">
<div className="h-full w-full overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
{hasTeam && (
<th className="text-right p-3 font-semibold">Queue</th>
)}
</tr>
</thead>
<tbody>
{participants.length === 0 ? (
<tr>
<td
colSpan={hasTeam ? 3 : 2}
className="text-center py-8 text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
) : (
participants.map((participant) => {
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
return (
<tr
key={participant.id}
className={`border-t transition-colors ${
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: virtualItem.start,
left: 0,
right: 0,
}}
>
<div className="md:hidden px-4 py-1">
<div
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
isDrafted
? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
: "hover:bg-muted/50"
: ""
}`}
title={
!isEligible && !isDrafted ? ineligibleReason : undefined
}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
<div className="flex flex-col gap-1 min-w-0">
<span
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
<div className="flex flex-wrap gap-1 items-center">
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
OVR {rank?.overallRank} · SPR {rank?.sportRank}
</span>
{isDrafted && (
<Badge variant="secondary" className="text-xs">
@ -536,71 +460,162 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
{hasTeam && (
<td className="p-3 text-right">
<div className="flex gap-2 justify-end items-center">
{!isDrafted && (
<>
{/* Queue icon button */}
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
onClick={() => onAddToQueue(participant.id)}
title={
!isEligible
? ineligibleReason
: "Add to queue"
}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>
<ListX className="h-4 w-4" />
</Button>
)}
{/* Draft button - always visible, disabled when not your turn */}
<Button
variant="default"
size="sm"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
title={
!canPick
? "Not your turn"
: !isEligible
? ineligibleReason
: undefined
}
>
Draft
</Button>
</>
)}
</div>
</td>
</div>
{hasTeam && !isDrafted && (
<div className="flex gap-2 flex-shrink-0">
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => onAddToQueue(participant.id)}
title={!isEligible ? ineligibleReason : "Add to queue"}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>
<ListX className="h-4 w-4" />
</Button>
)}
<Button
variant="default"
size="sm"
className="min-h-[44px]"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
title={
!canPick
? "Not your turn"
: !isEligible
? ineligibleReason
: undefined
}
>
Draft
</Button>
</div>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
<div
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors border-t ${
isDrafted
? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
: "hover:bg-muted/50"
}`}
title={
!isEligible && !isDrafted ? ineligibleReason : undefined
}
>
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
{rank?.overallRank}
</span>
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
{rank?.sportRank}
</span>
<div className="p-3">
<div className="flex items-center gap-2">
<span
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
{isDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!isDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
{isInQueue && !isDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
</div>
</div>
{hasTeam && (
<div className="p-3">
<div className="flex gap-2 justify-end items-center">
{!isDrafted && (
<>
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
onClick={() => onAddToQueue(participant.id)}
title={
!isEligible
? ineligibleReason
: "Add to queue"
}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>
<ListX className="h-4 w-4" />
</Button>
)}
<Button
variant="default"
size="sm"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
title={
!canPick
? "Not your turn"
: !isEligible
? ineligibleReason
: undefined
}
>
Draft
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);

View file

@ -1,4 +1,5 @@
import { memo, useMemo, useState } from "react";
import { TeamAvatar } from "~/components/TeamAvatar";
import {
ContextMenu,
ContextMenuContent,
@ -14,6 +15,8 @@ import {
import { Button } from "~/components/ui/button";
import { MoreVertical } from "lucide-react";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
import type { SeasonStatus } from "~/models/season";
type MobileSheetData =
| { type: "team"; teamId: string }
@ -27,6 +30,7 @@ interface DraftGridSectionProps {
team: {
id: string;
name: string;
logoUrl?: string | null;
};
}>;
draftGrid: Array<
@ -50,6 +54,8 @@ interface DraftGridSectionProps {
autodraftStatus: Record<string, { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
connectedTeams: Set<string>;
isCommissioner: boolean;
seasonStatus?: SeasonStatus;
draftPaused?: boolean;
onAdjustTimeBankOpen?: (teamId: string) => void;
onSetAutodraftOpen?: (teamId: string) => void;
onForceAutopick: (pickNumber: number, teamId: string) => void;
@ -67,6 +73,8 @@ export const DraftGridSection = memo(function DraftGridSection({
autodraftStatus,
connectedTeams,
isCommissioner,
seasonStatus,
draftPaused,
onAdjustTimeBankOpen,
onSetAutodraftOpen,
onForceAutopick,
@ -88,9 +96,7 @@ export const DraftGridSection = memo(function DraftGridSection({
<div className="flex-1 overflow-auto">
<div className="inline-block min-w-full min-h-full">
{/* Team Headers */}
<div className="flex gap-2 mb-2 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-1">
{/* Spacer for round column — sticky so it covers the corner when scrolling both axes */}
<div className="w-8 flex-shrink-0 sticky left-0 z-[6] bg-background/95" />
<div className="flex gap-1.5 mb-1.5 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-1">
{draftSlots.map((slot) => {
const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
@ -98,26 +104,32 @@ export const DraftGridSection = memo(function DraftGridSection({
const headerInner = (
<div className="relative">
<div className="flex justify-center mb-1">
<TeamAvatar
teamId={slot.team.id}
teamName={ownerMap[slot.team.id] || slot.team.name}
logoUrl={slot.team.logoUrl}
size="md"
/>
</div>
<div
className={`font-semibold text-sm truncate px-2 ${
className={`font-semibold text-xs px-1 flex items-center justify-center ${
slot.team.id === currentTeamId
? "text-electric font-bold"
: !isConnected
? "italic text-muted-foreground"
: ""
}`}
>
{ownerMap[slot.team.id] || slot.team.name}
>
{isAutodraft && (
<span className="mr-1 inline-flex items-center justify-center h-4 w-4 text-[10px] font-bold text-white bg-black shrink-0">A</span>
)}
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
</div>
<div
className={`text-xs font-mono px-2 ${getTimerColorClass(teamTime)}`}
className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}
>
{formatClockTime(teamTime)}
{isAutodraft && (
<span className="ml-1 text-muted-foreground">
(auto)
</span>
)}
</div>
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
<button
@ -134,7 +146,7 @@ export const DraftGridSection = memo(function DraftGridSection({
return (
<ContextMenu key={slot.id}>
<ContextMenuTrigger asChild>
<div className="flex-1 min-w-32 text-center cursor-context-menu">
<div className="flex-1 min-w-20 text-center cursor-context-menu">
{headerInner}
</div>
</ContextMenuTrigger>
@ -155,7 +167,7 @@ export const DraftGridSection = memo(function DraftGridSection({
}
return (
<div key={slot.id} className="flex-1 min-w-32 text-center">
<div key={slot.id} className="flex-1 min-w-20 text-center">
{headerInner}
</div>
);
@ -163,7 +175,7 @@ export const DraftGridSection = memo(function DraftGridSection({
</div>
{/* Draft Grid Rows */}
<div className="space-y-2">
<div className="space-y-1.5">
{draftGrid.map((roundPicks, roundIndex) => {
const round = roundIndex + 1;
const isEvenRound = round % 2 === 0;
@ -172,45 +184,19 @@ export const DraftGridSection = memo(function DraftGridSection({
: roundPicks;
return (
<div key={round} className="flex gap-2 items-stretch">
{/* Round label */}
<div className="w-8 flex-shrink-0 sticky left-0 z-[5] bg-background/95 flex items-center justify-center">
<span className="text-xs font-mono text-muted-foreground">R{round}</span>
</div>
<div key={round} className="flex gap-1.5 items-stretch">
{displayPicks.map((cell) => {
const isCurrent = cell.pickNumber === currentPick;
const isPicked = !!cell.pick;
const cellState = isPicked ? "picked" : isCurrent ? "current" : "upcoming";
const pickData = cell.pick
? { participant: { name: cell.pick.participant.name }, sport: { name: cell.pick.sport.name } }
: undefined;
const pendingCorona = cellState === "upcoming" ? { type: "pending" as const } : undefined;
const cellClassName = `relative flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
isCurrent
? "border-electric bg-electric/20 shadow-lg shadow-electric/25 ring-1 ring-electric/40"
: isPicked
? "bg-emerald-500/10 border-emerald-500/30"
: "border-border bg-card"
}`;
const cellInner = (
const mobileButtons = isCommissioner && (
<>
<div className="text-xs font-mono text-muted-foreground mb-1">
{cell.round}.
{String(cell.pickInRound).padStart(2, "0")}
</div>
{isPicked ? (
<div className="text-xs">
<div className="font-semibold truncate">
{cell.pick?.participant.name}
</div>
<div className="text-muted-foreground truncate">
{cell.pick?.sport.name}
</div>
</div>
) : isCurrent ? (
<div className="text-sm font-bold text-electric animate-pulse">
On Clock
</div>
) : null}
{/* Mobile commissioner button */}
{isCommissioner && !isPicked && isCurrent && (
{!isPicked && isCurrent && (
<button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "current-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
@ -218,7 +204,7 @@ export const DraftGridSection = memo(function DraftGridSection({
<MoreVertical className="h-3.5 w-3.5" />
</button>
)}
{isCommissioner && isPicked && (onReplacePick || onRollbackToPick) && (
{isPicked && (onReplacePick || onRollbackToPick) && (
<button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "picked-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
@ -229,17 +215,23 @@ export const DraftGridSection = memo(function DraftGridSection({
</>
);
// Commissioner context menu on the current unpicked cell
if (isCommissioner && !isPicked && isCurrent) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
<DraftPickCell
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
coronaState={pendingCorona}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
>
{cellInner}
</div>
{mobileButtons}
</DraftPickCell>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
@ -264,17 +256,23 @@ export const DraftGridSection = memo(function DraftGridSection({
);
}
// Commissioner context menu on already-picked cells
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
<DraftPickCell
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
coronaState={pendingCorona}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
>
{cellInner}
</div>
{mobileButtons}
</DraftPickCell>
</ContextMenuTrigger>
<ContextMenuContent>
{onReplacePick && (
@ -300,13 +298,19 @@ export const DraftGridSection = memo(function DraftGridSection({
}
return (
<div
<DraftPickCell
key={cell.pickNumber}
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
coronaState={pendingCorona}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
>
{cellInner}
</div>
{mobileButtons}
</DraftPickCell>
);
})}
</div>

View file

@ -0,0 +1,240 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DraftPickCell } from "./DraftPickCell";
const meta: Meta<typeof DraftPickCell> = {
title: "Draft/DraftPickCell",
component: DraftPickCell,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DraftPickCell>;
const basePickArgs = {
pickNumber: 1,
round: 1,
pickInRound: 1,
state: "picked" as const,
pick: {
participant: { name: "LeBron James" },
sport: { name: "Basketball" },
},
};
export const Upcoming: Story = {
args: {
pickNumber: 5,
round: 1,
pickInRound: 5,
state: "upcoming",
},
};
export const Current: Story = {
args: {
pickNumber: 5,
round: 1,
pickInRound: 5,
state: "current",
},
};
export const Picked: Story = {
args: basePickArgs,
};
export const PickedElectricCorona: Story = {
args: {
...basePickArgs,
corona: "electric",
},
};
export const PickedEmeraldCorona: Story = {
args: {
...basePickArgs,
corona: "emerald",
},
};
export const PickedAmberCorona: Story = {
args: {
...basePickArgs,
corona: "amber",
},
};
export const PickedCoralCorona: Story = {
args: {
...basePickArgs,
corona: "coral",
},
};
export const PickedLongNames: Story = {
name: "Picked — Long Names (truncation)",
args: {
pickNumber: 12,
round: 2,
pickInRound: 6,
state: "picked",
pick: {
participant: { name: "Alexander Ovechkin-Washington" },
sport: { name: "Ice Hockey — NHL 2025/26" },
},
},
};
export const AllStatesSideBySide: Story = {
name: "All States — Side by Side",
render: () => (
<div className="flex gap-2 items-stretch">
<DraftPickCell
pickNumber={1}
round={1}
pickInRound={1}
state="picked"
pick={basePickArgs.pick}
/>
<DraftPickCell
pickNumber={2}
round={1}
pickInRound={2}
state="current"
/>
<DraftPickCell
pickNumber={3}
round={1}
pickInRound={3}
state="upcoming"
/>
</div>
),
};
export const CoronaVariantsRow: Story = {
name: "Corona Variants — Side by Side",
render: () => (
<div className="flex gap-2 items-stretch">
{(
["gradient", "electric", "emerald", "amber", "coral"] as const
).map((variant, i) => (
<DraftPickCell
key={variant}
pickNumber={i + 1}
round={1}
pickInRound={i + 1}
state="picked"
pick={basePickArgs.pick}
corona={variant}
/>
))}
</div>
),
};
export const CoronaStateEliminated: Story = {
name: "Corona State — Eliminated (0 pts)",
args: {
...basePickArgs,
coronaState: { type: "eliminated", points: 0 },
},
};
export const CoronaStatePending: Story = {
name: "Corona State — Pending (no result)",
args: {
...basePickArgs,
coronaState: { type: "pending" },
},
};
export const CoronaStateScored100: Story = {
name: "Corona State — Scored 100%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 1, points: 100 },
},
};
export const CoronaStateScored70: Story = {
name: "Corona State — Scored 70%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 0.7, points: 70 },
},
};
export const CoronaStateScored45: Story = {
name: "Corona State — Scored 45%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 0.45, points: 45 },
},
};
export const CoronaStateScored25: Story = {
name: "Corona State — Scored 25%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 0.25, points: 25 },
},
};
export const CoronaStatesRow: Story = {
name: "Corona States — All Side by Side",
render: () => (
<div className="flex gap-2 items-stretch">
<DraftPickCell
pickNumber={1}
round={1}
pickInRound={1}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "eliminated", points: 0 }}
/>
<DraftPickCell
pickNumber={2}
round={1}
pickInRound={2}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "pending" }}
/>
<DraftPickCell
pickNumber={3}
round={1}
pickInRound={3}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 1, points: 100 }}
/>
<DraftPickCell
pickNumber={4}
round={1}
pickInRound={4}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 0.7, points: 70 }}
/>
<DraftPickCell
pickNumber={5}
round={1}
pickInRound={5}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 0.45, points: 45 }}
/>
<DraftPickCell
pickNumber={6}
round={1}
pickInRound={6}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 0.25, points: 25 }}
/>
</div>
),
};

View file

@ -0,0 +1,148 @@
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 };
type CoronaVariant =
| "gradient"
| "electric"
| "emerald"
| "amber"
| "coral";
type DraftPickCellProps = ComponentPropsWithoutRef<"div"> & {
pickNumber: number;
round: number;
pickInRound: number;
state: "picked" | "current" | "upcoming";
pick?: {
participant: { name: string };
sport: { name: string };
};
corona?: CoronaVariant;
coronaState?: CoronaState;
seasonStatus?: SeasonStatus;
draftPaused?: boolean;
};
const coronaClasses: Record<CoronaVariant, string> = {
gradient: "[background:linear-gradient(to_bottom,#adf661,#2ce1c1)]",
electric: "bg-electric",
emerald: "bg-emerald-500",
amber: "bg-amber-accent",
coral: "bg-coral-accent",
};
function getCoronaStyle(cs: CoronaState): CSSProperties {
switch (cs.type) {
case "eliminated":
return { background: "oklch(0.45 0.20 25)" };
case "pending":
return { background: "rgba(255, 255, 255, 0.08)" };
case "scored":
return {
background: "linear-gradient(to bottom, #adf661, #2ce1c1)",
opacity: cs.brightness,
};
}
}
export const DraftPickCell = forwardRef<HTMLDivElement, DraftPickCellProps>(
function DraftPickCell({
pickNumber,
round,
pickInRound,
state,
pick,
corona = "gradient",
coronaState,
seasonStatus,
draftPaused,
className,
children,
...rest
}, ref) {
const isCurrent = state === "current";
const showCorona = !isCurrent;
let currentLabel = "On The Clock";
if (seasonStatus === "pre_draft") {
currentLabel = "Up Next";
} else if (seasonStatus === "draft" && draftPaused) {
currentLabel = "Paused Draft";
}
return (
<div
ref={ref}
className={cn(
"relative flex-1 min-w-20 overflow-hidden",
className,
)}
{...rest}
>
{isCurrent ? (
<div className="absolute inset-0 rounded-lg [background:linear-gradient(to_bottom,#adf661,#2ce1c1)] animate-pulse-gradient" />
) : coronaState ? (
<div
className="absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg"
style={getCoronaStyle(coronaState)}
/>
) : state === "picked" ? (
<div
className={cn(
"absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg",
coronaClasses[corona],
)}
/>
) : state === "upcoming" ? (
<div
className="absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg"
style={{ background: "rgba(255, 255, 255, 0.08)" }}
/>
) : null}
<div
className={cn(
"relative z-10 h-14 border-2 rounded-lg p-1.5 transition-all",
(showCorona || isCurrent) && "mr-1",
isCurrent
? "bg-muted/80 border-transparent"
: showCorona
? "bg-muted border-transparent"
: "border-border bg-card",
)}
title={`Overall Pick #${pickNumber}`}
>
<div className={cn("flex flex-col", isCurrent ? "items-center justify-center h-full" : "gap-0.5")}>
{!isCurrent && (
<div className="flex items-center justify-between">
<div className="text-[10px] font-mono text-muted-foreground leading-3">
{round}.{String(pickInRound).padStart(2, "0")}
</div>
{coronaState && coronaState.type !== "pending" && (
<span className={`text-[10px] font-mono font-bold ${coronaState.type === "eliminated" ? "text-coral-accent" : "text-emerald-400"}`}>
{coronaState.type === "scored" && "▲"}{coronaState.points}
</span>
)}
</div>
)}
{state === "picked" && pick ? (
<>
<div className="font-semibold truncate text-xs leading-4">{pick.participant.name}</div>
<div className="text-muted-foreground truncate text-[10px] leading-3">
{pick.sport.name}
</div>
</>
) : isCurrent ? (
<div className="text-sm font-bold text-white">{currentLabel}</div>
) : null}
</div>
{children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>}
</div>
</div>
);
});

View file

@ -1,8 +1,9 @@
import { memo, useMemo } from "react";
import { memo, useMemo, Fragment } from "react";
import { TeamAvatar } from "~/components/TeamAvatar";
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
interface DraftSummaryViewProps {
draftSlots: Array<{ id: string; team: { id: string; name: string } }>;
draftSlots: Array<{ id: string; team: { id: string; name: string; logoUrl?: string | null } }>;
ownerMap?: Record<string, string>;
picks: DraftPick[];
sports: Array<{ id: string; name: string }>;
@ -58,90 +59,93 @@ export const DraftSummaryView = memo(function DraftSummaryView({
);
}
const gridCols = `140px 96px repeat(${draftSlots.length}, minmax(80px, 1fr))`;
return (
<div className="w-full h-full overflow-auto">
<table className="w-full border-collapse">
<thead className="sticky top-0 bg-background z-10">
<tr>
<th
scope="col"
className="border-r border-b border-border p-2 text-left font-semibold bg-muted sticky left-0 z-20 min-w-[120px]"
<div className="grid w-full" style={{ gridTemplateColumns: gridCols }}>
{/* Header row */}
<div className="sticky top-0 left-0 z-30 bg-muted border-r border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Sport
</div>
<div className="sticky top-0 z-20 bg-muted border-r border-b border-border px-2 py-2 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Total
</div>
{draftSlots.map((slot, index) => {
const ownerName = ownerMap[slot.team.id];
const isLast = index === draftSlots.length - 1;
const used = flexPicksUsedByTeam.get(slot.team.id) ?? 0;
const flexesExhausted = flexPicksAvailable > 0 && used >= flexPicksAvailable;
return (
<div
key={slot.id}
className={`sticky top-0 z-20 bg-muted/40 border-b border-border px-1 py-2 min-w-0 ${!isLast ? "border-r" : ""}`}
>
Sport
</th>
<th
scope="col"
className="border-r border-b border-border p-2 text-center font-semibold bg-muted min-w-[48px]"
>
Total
</th>
{draftSlots.map((slot, index) => {
const displayName = ownerMap[slot.team.id] || slot.team.name;
const isLast = index === draftSlots.length - 1;
const used = flexPicksUsedByTeam.get(slot.team.id) ?? 0;
const flexesExhausted = flexPicksAvailable > 0 && used >= flexPicksAvailable;
return (
<th
key={slot.id}
scope="col"
className={`border-b border-border p-2 text-center font-semibold bg-muted/50 min-w-[60px] ${!isLast ? "border-r" : ""}`}
>
<span className="text-sm">{displayName}</span>
{flexPicksAvailable > 0 && (
<div className={`text-xs mt-0.5 ${flexesExhausted ? "text-destructive font-medium" : "font-normal text-muted-foreground"}`}>
{used} of {flexPicksAvailable} flexes
</div>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sports.map((sport, sportIndex) => {
const isLastRow = sportIndex === sports.length - 1;
const { total: sportTotal, teams: sportTeamCount } = sportStats.get(sport.id) ?? { total: 0, teams: 0 };
return (
<tr key={sport.id}>
<th
scope="row"
className={`border-r border-border p-2 font-medium text-sm text-left bg-muted sticky left-0 z-10 ${!isLastRow ? "border-b" : ""}`}
>
{sport.name}
</th>
<td
className={`border-r border-border p-2 text-center text-sm font-semibold bg-muted/30 ${!isLastRow ? "border-b" : ""}`}
>
{sportTotal > 0 ? (
<>
<div>{sportTotal}</div>
<div className="text-xs font-normal text-muted-foreground mt-0.5">
{sportTeamCount} team{sportTeamCount !== 1 ? "s" : ""}
</div>
</>
) : null}
</td>
{draftSlots.map((slot, slotIndex) => {
const count =
picksByTeamAndSport.get(slot.team.id)?.get(sport.id)
?.length ?? 0;
const isLast = slotIndex === draftSlots.length - 1;
return (
<td
key={`${slot.team.id}-${sport.id}`}
className={`border-border p-2 text-center text-sm ${
count >= 2 ? "bg-accent/30 font-semibold" : "bg-background"
} ${!isLastRow ? "border-b" : ""} ${!isLast ? "border-r" : ""}`}
>
{count > 0 ? count : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
<div className="flex flex-col items-center gap-1">
<TeamAvatar
teamId={slot.team.id}
teamName={ownerName || slot.team.name}
logoUrl={slot.team.logoUrl}
size="md"
/>
<div className="text-xs font-semibold text-center truncate w-full">
{ownerName || slot.team.name}
</div>
{flexPicksAvailable > 0 && (
<span className={`text-xs ${flexesExhausted ? "text-destructive font-medium" : "text-muted-foreground"}`}>
{used}/{flexPicksAvailable} flex picks
</span>
)}
</div>
</div>
);
})}
{/* Data rows */}
{sports.map((sport, sportIndex) => {
const isLastRow = sportIndex === sports.length - 1;
const { total: sportTotal, teams: sportTeamCount } =
sportStats.get(sport.id) ?? { total: 0, teams: 0 };
return (
<Fragment key={sport.id}>
<div className={`sticky left-0 z-10 bg-muted border-r border-border px-3 py-2.5 font-medium text-sm whitespace-nowrap ${!isLastRow ? "border-b" : ""}`}>
{sport.name}
</div>
<div className={`border-r border-border px-2 py-2.5 text-center bg-muted/20 ${!isLastRow ? "border-b" : ""}`}>
{sportTotal > 0 ? (
<>
<div className="text-sm font-bold tabular-nums">{sportTotal}</div>
<div className="text-xs text-muted-foreground">{sportTeamCount}/{draftSlots.length} teams</div>
</>
) : (
<span className="text-muted-foreground/40 text-sm"></span>
)}
</div>
{draftSlots.map((slot, slotIndex) => {
const count = picksByTeamAndSport.get(slot.team.id)?.get(sport.id)?.length ?? 0;
const isLast = slotIndex === draftSlots.length - 1;
const isFlex = count >= 2;
return (
<div
key={`${slot.team.id}-${sport.id}`}
className={`border-border px-3 py-2.5 text-center ${isFlex ? "bg-amber-500/10" : "bg-background"} ${!isLastRow ? "border-b" : ""} ${!isLast ? "border-r" : ""}`}
>
{count > 0 ? (
<span className={`text-sm font-bold tabular-nums ${isFlex ? "text-amber-600 dark:text-amber-400" : ""}`}>
{count}
</span>
) : (
<span className="text-muted-foreground/30 text-sm"></span>
)}
</div>
);
})}
</Fragment>
);
})}
</div>
</div>
);
});

View file

@ -0,0 +1,145 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MiniDraftGrid } from "./MiniDraftGrid";
const meta: Meta<typeof MiniDraftGrid> = {
title: "Draft/MiniDraftGrid",
component: MiniDraftGrid,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof MiniDraftGrid>;
const teamNames = ["Alpha", "Bravo", "Charlie", "Delta"];
const ownerMap: Record<string, string> = {};
const draftSlots = teamNames.map((name, i) => {
const id = `team-${i + 1}`;
ownerMap[id] = name;
return { id, draftOrder: i + 1, team: { id, name, logoUrl: null } };
});
function buildRound(round: number, teamCount: number, picks: Array<number | null>) {
const isEvenRound = round % 2 === 0;
return Array.from({ length: teamCount }, (_, i) => {
const teamIndex = isEvenRound ? teamCount - 1 - i : i;
const pickNumber = (round - 1) * teamCount + i + 1;
const pick = picks[i];
return {
pickNumber,
round,
pickInRound: i + 1,
teamId: draftSlots[teamIndex].id,
...(pick !== null
? {
pick: {
participant: { name: `Participant ${pick}` },
sport: { name: ["NFL", "NBA", "NHL", "MLB"][pick % 4] },
},
}
: {}),
};
});
}
const fullGrid = [
buildRound(1, 4, [1, 2, 3, 4]),
buildRound(2, 4, [5, 6, null, null]),
buildRound(3, 4, [null, null, null, null]),
buildRound(4, 4, [null, null, null, null]),
];
export const Round1ShowsFirstTwo: Story = {
name: "Round 1 — Shows Rounds 1 & 2",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 1,
currentRound: 1,
ownerMap,
},
};
export const Round2: Story = {
name: "Round 2 — Shows Rounds 1 & 2",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 5,
currentRound: 2,
ownerMap,
},
};
export const Round3: Story = {
name: "Round 3 — Shows Rounds 2 & 3",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 9,
currentRound: 3,
ownerMap,
},
};
export const Round4: Story = {
name: "Round 4 — Shows Rounds 3 & 4",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 13,
currentRound: 4,
ownerMap,
},
};
export const AllPicked: Story = {
name: "Both Rounds Fully Picked",
args: {
draftSlots,
draftGrid: [
buildRound(1, 4, [1, 2, 3, 4]),
buildRound(2, 4, [5, 6, 7, 8]),
buildRound(3, 4, [null, null, null, null]),
],
currentPick: 9,
currentRound: 3,
ownerMap,
},
};
export const SixTeams: Story = {
name: "6 Teams — Snake Draft",
args: (() => {
const names6 = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"];
const ownerMap6: Record<string, string> = {};
const slots6 = names6.map((name, i) => {
const id = `team-${i + 1}`;
ownerMap6[id] = name;
return { id, draftOrder: i + 1, team: { id, name, logoUrl: null } };
});
const grid6 = [
buildRound(1, 6, [1, 2, 3, 4, 5, 6]),
buildRound(2, 6, [7, 8, null, null, null, null]),
];
return {
draftSlots: slots6,
draftGrid: grid6,
currentPick: 9,
currentRound: 2,
ownerMap: ownerMap6,
};
})(),
};
export const EmptyGrid: Story = {
name: "Empty Grid",
args: {
draftSlots,
draftGrid: [],
currentPick: 1,
currentRound: 1,
ownerMap,
},
};

View file

@ -0,0 +1,289 @@
import { memo, useState, useEffect, useRef } from "react";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
import type { SeasonStatus } from "~/models/season";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
const ROW_GAP = 6; // gap-1.5
const ANIMATION_MS = 300;
function getRoundIndices(round: number): [number, number] {
if (round <= 2) return [0, 1];
return [round - 2, round - 1];
}
export interface MiniDraftGridProps {
draftSlots: Array<{
id: string;
draftOrder: number;
team: {
id: string;
name: string;
logoUrl?: string | null;
};
}>;
draftGrid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: {
participant: {
name: string;
};
sport: {
name: string;
};
};
}>
>;
currentPick: number;
currentRound: number;
ownerMap?: Record<string, string>;
teamTimers?: Record<string, number | undefined>;
autodraftStatus?: Record<string, { isEnabled: boolean }>;
seasonStatus?: SeasonStatus;
draftPaused?: boolean;
onForceAutopick?: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen?: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void;
onRollbackToPick?: (pickNumber: number) => void;
}
export const MiniDraftGrid = memo(function MiniDraftGrid({
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap = {},
teamTimers = {},
autodraftStatus = {},
seasonStatus,
draftPaused,
onForceAutopick,
onForceManualPickOpen,
onReplacePick,
onRollbackToPick,
}: MiniDraftGridProps) {
const [displayedIndices, setDisplayedIndices] = useState<[number, number]>(() => getRoundIndices(currentRound));
const [extraIndex, setExtraIndex] = useState<number | null>(null);
const [sliding, setSliding] = useState(false);
const animGenRef = useRef(0);
const prevRoundRef = useRef(currentRound);
const firstRowRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const currentCellRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (firstRowRef.current) {
setRowHeight(firstRowRef.current.getBoundingClientRect().height);
}
}, [draftGrid]);
useEffect(() => {
const container = scrollContainerRef.current;
const cell = currentCellRef.current;
if (!container || !cell) return;
const containerRect = container.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
const scrollLeft = container.scrollLeft + cellRect.left - containerRect.left - (containerRect.width - cellRect.width) / 2;
container.scrollTo({ left: Math.max(0, scrollLeft), behavior: "smooth" });
}, [currentPick]);
useEffect(() => {
const prev = prevRoundRef.current;
prevRoundRef.current = currentRound;
const newIndices = getRoundIndices(currentRound);
const prevIndices = getRoundIndices(prev);
if (newIndices[0] === prevIndices[0]) return;
// Non-sequential jump or animation already running: snap and invalidate any in-flight animation
if (newIndices[0] !== prevIndices[0] + 1 || animGenRef.current > 0) {
animGenRef.current++; // invalidate any in-flight animation closure
setDisplayedIndices(newIndices);
setExtraIndex(null);
setSliding(false);
return;
}
const gen = ++animGenRef.current;
setExtraIndex(newIndices[1]);
// Two rAFs: first lets React flush the extra row to DOM, second starts the CSS transition
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setSliding(true);
setTimeout(() => {
if (animGenRef.current !== gen) return; // interrupted by snap
setDisplayedIndices(newIndices);
setExtraIndex(null);
setSliding(false);
animGenRef.current = 0;
}, ANIMATION_MS + 20);
});
});
}, [currentRound]);
if (draftGrid.length === 0) return null;
// h-14 (56px) + gap-1.5 (6px) fallback until first measurement
const effectiveRowHeight = rowHeight > 0 ? rowHeight : 56;
const roundIndicesToRender: number[] = extraIndex !== null
? [displayedIndices[0], displayedIndices[1], extraIndex]
: [displayedIndices[0], displayedIndices[1]];
return (
<div ref={scrollContainerRef} className="overflow-x-auto">
<div className="inline-block min-w-full">
{/* Team header */}
<div className="flex gap-1.5 mb-1.5">
{draftSlots.map((slot) => {
const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false;
return (
<div key={slot.id} className="flex-1 min-w-20 text-center">
<div className="text-xs font-medium truncate px-1 flex items-center justify-center gap-0.5">
{isAutodraft && (
<span className="inline-flex items-center justify-center h-3.5 w-3.5 text-[9px] font-bold text-white bg-black shrink-0">A</span>
)}
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
</div>
<div className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}>
{formatClockTime(teamTime)}
</div>
</div>
);
})}
</div>
{/* Rows — clipped to 2-row height, slides to reveal new round */}
<div style={{ height: `${effectiveRowHeight * 2 + ROW_GAP}px`, overflow: "hidden" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${ROW_GAP}px`,
transform: sliding ? `translateY(-${effectiveRowHeight + ROW_GAP}px)` : "translateY(0)",
transition: sliding ? `transform ${ANIMATION_MS}ms ease-in-out` : "none",
}}
>
{roundIndicesToRender.map((roundIndex) => {
if (roundIndex >= draftGrid.length) return null;
const roundPicks = draftGrid[roundIndex];
const round = roundIndex + 1;
const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound ? [...roundPicks].toReversed() : roundPicks;
return (
<div key={round} ref={roundIndex === displayedIndices[0] ? firstRowRef : undefined} className="flex gap-1.5 items-stretch flex-shrink-0">
{displayPicks.map((cell) => {
const isCurrent = cell.pickNumber === currentPick;
const isPicked = !!cell.pick;
const cellState = isPicked ? "picked" : isCurrent ? "current" : "upcoming";
const pickData = cell.pick
? {
participant: { name: cell.pick.participant.name },
sport: { name: cell.pick.sport.name },
}
: undefined;
if (!isPicked && isCurrent && (onForceAutopick || onForceManualPickOpen)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<DraftPickCell
ref={isCurrent ? currentCellRef : undefined}
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
/>
</ContextMenuTrigger>
<ContextMenuContent>
{onForceAutopick && (
<ContextMenuItem onClick={() => onForceAutopick(cell.pickNumber, cell.teamId)}>
Force Auto Pick
</ContextMenuItem>
)}
{onForceManualPickOpen && (
<ContextMenuItem onClick={() => onForceManualPickOpen(cell.pickNumber, cell.teamId)}>
Force Manual Pick
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
if (isPicked && (onReplacePick || onRollbackToPick)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<DraftPickCell
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
/>
</ContextMenuTrigger>
<ContextMenuContent>
{onReplacePick && (
<ContextMenuItem onClick={() => onReplacePick(cell.pickNumber, cell.teamId)}>
Replace Pick
</ContextMenuItem>
)}
{onRollbackToPick && (
<ContextMenuItem
onClick={() => onRollbackToPick(cell.pickNumber)}
className="text-destructive focus:text-destructive"
>
Roll Back to This Pick
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
return (
<DraftPickCell
key={cell.pickNumber}
ref={isCurrent ? currentCellRef : undefined}
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
/>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,122 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueueSection } from "./QueueSection";
const meta: Meta<typeof QueueSection> = {
title: "Draft/QueueSection",
component: QueueSection,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof QueueSection>;
const participants = [
{ id: "p1", name: "Max Verstappen", sport: { name: "F1" } },
{ id: "p2", name: "LeBron James", sport: { name: "Basketball" } },
{ id: "p3", name: "Scottie Scheffler", sport: { name: "Golf" } },
{ id: "p4", name: "Alexander Ovechkin-Washington", sport: { name: "Ice Hockey" } },
];
const queue = [
{ id: "q1", participantId: "p1" },
{ id: "q2", participantId: "p2" },
{ id: "q3", participantId: "p3" },
];
export const Empty: Story = {
args: {
queue: [],
availableParticipants: participants,
canPick: false,
onRemoveFromQueue: () => {},
onReorder: () => {},
onMakePick: () => {},
},
};
export const NotYourTurn: Story = {
name: "With Items — Not Your Turn",
args: {
queue,
availableParticipants: participants,
canPick: false,
onRemoveFromQueue: () => {},
onReorder: () => {},
onMakePick: () => {},
},
};
export const YourTurn: Story = {
name: "With Items — Your Turn (Draft button visible)",
args: {
queue,
availableParticipants: participants,
canPick: true,
onRemoveFromQueue: () => {},
onReorder: () => {},
onMakePick: () => {},
},
};
export const LongNameYourTurn: Story = {
name: "Long Name — Your Turn (two-line layout)",
args: {
queue: [{ id: "q1", participantId: "p4" }],
availableParticipants: participants,
canPick: true,
onRemoveFromQueue: () => {},
onReorder: () => {},
onMakePick: () => {},
},
};
export const LongNameNotYourTurn: Story = {
name: "Long Name — Not Your Turn (truncation only)",
args: {
queue: [{ id: "q1", participantId: "p4" }],
availableParticipants: participants,
canPick: false,
onRemoveFromQueue: () => {},
onReorder: () => {},
onMakePick: () => {},
},
};
export const SingleItem: Story = {
name: "Single Item — Your Turn",
args: {
queue: [{ id: "q1", participantId: "p1" }],
availableParticipants: participants,
canPick: true,
onRemoveFromQueue: () => {},
onReorder: () => {},
onMakePick: () => {},
},
};
const manyParticipants = [
{ id: "p1", name: "Max Verstappen", sport: { name: "F1" } },
{ id: "p2", name: "LeBron James", sport: { name: "Basketball" } },
{ id: "p3", name: "Scottie Scheffler", sport: { name: "Golf" } },
{ id: "p4", name: "Alexander Ovechkin-Washington", sport: { name: "Ice Hockey" } },
{ id: "p5", name: "Patrick Mahomes", sport: { name: "NFL" } },
{ id: "p6", name: "Connor McDavid", sport: { name: "Ice Hockey" } },
{ id: "p7", name: "Novak Djokovic", sport: { name: "Tennis" } },
{ id: "p8", name: "Erling Haaland", sport: { name: "Soccer" } },
];
const longQueue = manyParticipants.map((p, i) => ({ id: `q${i + 1}`, participantId: p.id }));
export const FullQueue: Story = {
name: "Full Queue — Your Turn (scroll check)",
args: {
queue: longQueue,
availableParticipants: manyParticipants,
canPick: true,
onRemoveFromQueue: () => {},
onReorder: () => {},
onMakePick: () => {},
},
};

View file

@ -2,7 +2,6 @@ import { memo, useCallback, useMemo } from "react";
import { GripVertical } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { AutodraftSettings } from "~/components/AutodraftSettings";
import {
DndContext,
closestCenter,
@ -31,17 +30,8 @@ interface QueueSectionProps {
name: string;
sport: { name: string };
}>;
seasonId: string;
teamId: string;
isMyTurn: boolean;
canPick: boolean;
userAutodraft: {
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
};
onRemoveFromQueue: (queueId: string) => void;
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => void;
onReorder: (participantIds: string[]) => void;
onMakePick?: (participantId: string) => void;
}
@ -85,45 +75,45 @@ const SortableQueueItem = memo(function SortableQueueItem({
ref={setNodeRef}
style={style}
{...attributes}
className={`flex items-center justify-between p-2 rounded-lg ${
className={`p-2 rounded-lg ${
canPick ? "bg-electric/10 border border-electric/40" : "bg-muted"
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex items-center gap-2">
<div
ref={setActivatorNodeRef}
{...listeners}
className="flex items-center gap-2 flex-shrink-0 cursor-grab active:cursor-grabbing touch-none"
className="w-10 flex items-center gap-2 flex-shrink-0 cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
<Badge variant="default" className="text-xs">{index + 1}</Badge>
</div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm truncate">{participantName}</p>
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
{canPick && onDraft && (
<Button
variant="default"
size="sm"
className="h-7 text-xs bg-electric text-background hover:bg-electric/90"
onClick={() => onDraft(item.participantId)}
>
Draft
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
className="h-7 w-7 flex-shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onRemove(item.id)}
title="Remove from queue"
>
<span className="text-lg">×</span>
</Button>
</div>
{canPick && onDraft && (
<div className="mt-1.5 pl-10">
<Button
variant="default"
size="sm"
className="h-7 text-xs w-full bg-electric text-background hover:bg-electric/90"
onClick={() => onDraft(item.participantId)}
>
Draft
</Button>
</div>
)}
</div>
);
});
@ -131,13 +121,8 @@ const SortableQueueItem = memo(function SortableQueueItem({
export const QueueSection = memo(function QueueSection({
queue,
availableParticipants,
seasonId,
teamId,
isMyTurn,
canPick,
userAutodraft,
onRemoveFromQueue,
onAutodraftUpdate,
onReorder,
onMakePick,
}: QueueSectionProps) {
@ -187,7 +172,7 @@ export const QueueSection = memo(function QueueSection({
items={queueIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5 mb-4">
<div className="space-y-1.5">
{queue.map((item, index) => {
const participant = participantMap.get(item.participantId);
return (
@ -207,17 +192,6 @@ export const QueueSection = memo(function QueueSection({
</SortableContext>
</DndContext>
)}
{/* Autodraft Settings */}
<AutodraftSettings
seasonId={seasonId}
teamId={teamId}
isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn}
onUpdate={onAutodraftUpdate}
/>
</div>
);
});

View file

@ -0,0 +1,56 @@
import { memo, useEffect, useRef, useState } from "react";
type Pick = {
id: string;
pickNumber: number;
participant: { name: string };
sport: { name: string };
team: { name: string };
};
export const RecentPicksFeed = memo(function RecentPicksFeed({ picks }: { picks: Pick[] }) {
const recentPicks = picks.slice(-3).reverse();
const prevNewestIdRef = useRef<string | undefined>(picks.at(-1)?.id);
const [animatingId, setAnimatingId] = useState<string | undefined>(undefined);
useEffect(() => {
const newest = recentPicks[0];
if (newest && newest.id !== prevNewestIdRef.current) {
prevNewestIdRef.current = newest.id;
setAnimatingId(newest.id);
const t = setTimeout(() => setAnimatingId(undefined), 450);
return () => clearTimeout(t);
}
}, [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>
);
});

View file

@ -44,14 +44,11 @@ export const SidebarRecentPicks = memo(function SidebarRecentPicks({ picks }: Si
<p className="text-xs text-muted-foreground truncate">
{pick.sport.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{pick.team.name}
</p>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<p className="font-medium text-xs truncate max-w-[100px]">{pick.team.name}</p>
<p className="text-xs text-muted-foreground">
R{pick.round}
</p>
</div>
</div>
))}
</div>

View file

@ -1,5 +1,4 @@
import { memo, useEffect, useMemo, useState } from "react";
import { Badge } from "~/components/ui/badge";
import {
Select,
SelectContent,
@ -7,6 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { DraftPickCell } from "./DraftPickCell";
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
interface TeamRosterViewProps {
@ -14,6 +14,7 @@ interface TeamRosterViewProps {
ownerMap?: Record<string, string>;
picks: DraftPick[];
sports: Array<{ id: string; name: string }>;
totalRounds: number;
}
export const TeamRosterView = memo(function TeamRosterView({
@ -21,12 +22,12 @@ export const TeamRosterView = memo(function TeamRosterView({
ownerMap = {},
picks,
sports,
totalRounds,
}: TeamRosterViewProps) {
const [selectedTeamId, setSelectedTeamId] = useState(
draftSlots[0]?.team.id ?? ""
);
// Reset selection if the selected team is no longer present in the slot list
useEffect(() => {
if (
draftSlots.length > 0 &&
@ -46,16 +47,42 @@ export const TeamRosterView = memo(function TeamRosterView({
[picksByTeamAndSport, selectedTeamId]
);
const sportsWithPicks = useMemo(
const flexPicksAvailable = useMemo(
() => Math.max(0, totalRounds - sports.length),
[totalRounds, sports.length]
);
const flexPicksUsed = useMemo(() => {
let used = 0;
teamPicks?.forEach((sportPicks) => {
if (sportPicks.length > 1) used += sportPicks.length - 1;
});
return used;
}, [teamPicks]);
const flexPicksRemaining = useMemo(
() => flexPicksAvailable - flexPicksUsed,
[flexPicksAvailable, flexPicksUsed]
);
const missingSports = useMemo(
() => sports.filter((s) => (teamPicks?.get(s.id)?.length ?? 0) === 0),
[sports, teamPicks]
);
const draftedSports = useMemo(
() => sports.filter((s) => (teamPicks?.get(s.id)?.length ?? 0) > 0),
[sports, teamPicks]
);
const totalPicks = useMemo(() => {
let count = 0;
teamPicks?.forEach((sp) => (count += sp.length));
return count;
}, [teamPicks]);
const flexColor =
flexPicksAvailable === 0
? "text-muted-foreground"
: flexPicksRemaining <= 0
? "text-destructive"
: flexPicksRemaining <= Math.ceil(flexPicksAvailable * 0.25)
? "text-amber-500 dark:text-amber-400"
: "text-emerald-500 dark:text-emerald-400";
if (draftSlots.length === 0) {
return (
@ -68,6 +95,7 @@ export const TeamRosterView = memo(function TeamRosterView({
return (
<div className="h-full overflow-y-auto">
<div className="p-4 space-y-4">
{/* Team selector */}
<div className="space-y-1">
<label
htmlFor="roster-team-select"
@ -89,31 +117,76 @@ export const TeamRosterView = memo(function TeamRosterView({
</Select>
</div>
{totalPicks === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No picks yet
</p>
{/* Mini dashboard */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-card px-3 py-2.5 space-y-0.5">
<p className="text-xs text-muted-foreground font-medium">Flex Picks</p>
{flexPicksAvailable === 0 ? (
<p className="text-sm font-semibold text-muted-foreground">N/A</p>
) : (
<>
<p className={`text-xl font-bold tabular-nums leading-none ${flexColor}`}>
{flexPicksRemaining}
<span className="text-sm font-normal text-muted-foreground ml-1">
/ {flexPicksAvailable}
</span>
</p>
<p className="text-xs text-muted-foreground">remaining</p>
</>
)}
</div>
<div className="rounded-lg border bg-card px-3 py-2.5 space-y-0.5">
<p className="text-xs text-muted-foreground font-medium">Sports Remaining</p>
<p className={`text-xl font-bold tabular-nums leading-none ${missingSports.length > 0 ? "text-foreground" : "text-emerald-500 dark:text-emerald-400"}`}>
{missingSports.length}
<span className="text-sm font-normal text-muted-foreground ml-1">
/ {sports.length}
</span>
</p>
<p className="text-xs text-muted-foreground">to draft</p>
</div>
</div>
{/* Missing sports list */}
{missingSports.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1.5">Still Needed</p>
<p className="text-sm text-muted-foreground">
{missingSports.map((s) => s.name).join(" · ")}
</p>
</div>
)}
{/* Per-sport pick cells */}
{draftedSports.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No picks yet</p>
) : (
sportsWithPicks.map((sport) => {
const sportPicks = teamPicks?.get(sport.id) ?? [];
return (
<div key={sport.id}>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold">{sport.name}</h3>
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{sportPicks.length}
</Badge>
<div className="space-y-3">
{draftedSports.map((sport) => {
const sportPicks = teamPicks?.get(sport.id) ?? [];
return (
<div key={sport.id}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1.5">
{sport.name}
</p>
<div className="flex flex-wrap gap-1">
{sportPicks.map((pick) => (
<DraftPickCell
key={pick.id}
pickNumber={pick.pickNumber}
round={pick.round}
pickInRound={pick.pickInRound}
state="picked"
pick={pick}
corona="gradient"
className="w-44 flex-none"
/>
))}
</div>
</div>
<ul className="space-y-1 pl-2">
{sportPicks.map((pick) => (
<li key={pick.id} className="text-sm text-foreground">
{pick.participant.name}
</li>
))}
</ul>
</div>
);
})
);
})}
</div>
)}
</div>
</div>

View file

@ -1,5 +1,8 @@
export type DraftPick = {
id: string;
pickNumber: number;
round: number;
pickInRound: number;
team: { id: string; name: string };
participant: { id: string; name: string };
sport: { id: string; name: string };

View file

@ -0,0 +1,172 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CommissionersPanel } from "./CommissionersPanel";
import type { AuditLogEntry } from "~/models/audit-log";
const meta: Meta<typeof CommissionersPanel> = {
title: "League/CommissionersPanel",
component: CommissionersPanel,
parameters: {
layout: "padded",
},
args: {
auditLogUrl: "/leagues/league-abc-123/audit-log",
currentUserId: "user-alice",
},
};
export default meta;
type Story = StoryObj<typeof CommissionersPanel>;
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const commissioners = [
{ id: "c1", userId: "user-alice" },
{ id: "c2", userId: "user-bob" },
];
const commissionerMap: Record<string, string> = {
"user-alice": "Alice Johnson",
"user-bob": "Bob Smith",
};
const teams = [
{ id: "t1", ownerId: "user-alice" },
{ id: "t2", ownerId: "user-bob" },
{ id: "t3", ownerId: "user-carol" },
{ id: "t4", ownerId: null },
];
const activityEntries: AuditLogEntry[] = [
{
id: "log-1",
seasonId: "season-1",
leagueId: "league-abc-123",
actorClerkId: "user-alice",
actorDisplayName: "Alice Johnson",
action: "draft_order_randomized",
affectedTeamIds: null,
details: null,
createdAt: new Date("2026-04-13T10:30:00Z"),
},
{
id: "log-2",
seasonId: "season-1",
leagueId: "league-abc-123",
actorClerkId: "user-alice",
actorDisplayName: "Alice Johnson",
action: "draft_settings_changed",
affectedTeamIds: null,
details: { changedFields: ["draftRounds", "draftTimerMode"] },
createdAt: new Date("2026-04-12T18:15:00Z"),
},
{
id: "log-3",
seasonId: "season-1",
leagueId: "league-abc-123",
actorClerkId: "user-bob",
actorDisplayName: "Bob Smith",
action: "league_settings_changed",
affectedTeamIds: null,
details: { changedFields: ["name"] },
createdAt: new Date("2026-04-11T09:00:00Z"),
},
];
// ─── Stories ──────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
commissioners,
commissionerMap,
teams,
activityEntries,
},
};
export const NoActivity: Story = {
name: "No Recent Activity",
args: {
commissioners,
commissionerMap,
teams,
activityEntries: [],
},
};
export const SingleCommissioner: Story = {
name: "Single Commissioner (is self, has team)",
args: {
commissioners: [{ id: "c1", userId: "user-alice" }],
commissionerMap: { "user-alice": "Alice Johnson" },
teams,
activityEntries,
},
};
export const CommissionerWithoutTeam: Story = {
name: "Commissioner Without a Team",
args: {
commissioners: [
{ id: "c1", userId: "user-alice" },
{ id: "c2", userId: "user-nomatch" },
],
commissionerMap: {
"user-alice": "Alice Johnson",
"user-nomatch": "Charlie (no team)",
},
teams,
activityEntries: [],
},
};
export const LongNames: Story = {
name: "Long Commissioner Names (overflow test)",
args: {
commissioners: [
{ id: "c1", userId: "user-alice" },
{ id: "c2", userId: "user-long" },
],
commissionerMap: {
"user-alice": "Alice Johnson-Bartholomew-Richardson",
"user-long": "Bartholomew Christopherson-Nightingale III",
},
teams,
activityEntries,
},
};
export const ManyCommissioners: Story = {
name: "Many Commissioners",
args: {
commissioners: [
{ id: "c1", userId: "user-alice" },
{ id: "c2", userId: "user-bob" },
{ id: "c3", userId: "user-carol" },
{ id: "c4", userId: "user-dave" },
],
commissionerMap: {
"user-alice": "Alice Johnson",
"user-bob": "Bob Smith",
"user-carol": "Carol Williams",
"user-dave": "Dave Martinez",
},
teams: [
{ id: "t1", ownerId: "user-alice" },
{ id: "t2", ownerId: "user-bob" },
{ id: "t3", ownerId: "user-carol" },
// dave has no team
],
activityEntries,
},
};
export const NotSelf: Story = {
name: "Viewer is not a commissioner",
args: {
commissioners,
commissionerMap,
teams,
activityEntries,
currentUserId: "user-carol",
},
};

View file

@ -0,0 +1,121 @@
import { format } from "date-fns";
import { Activity, ChevronRight, Crown } from "lucide-react";
import { Link } from "react-router";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { formatAuditDetail } from "~/lib/audit-log-display";
import type { AuditLogEntry } from "~/models/audit-log";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CommissionerEntry {
id: string;
userId: string;
}
export interface CommissionersPanelProps {
commissioners: CommissionerEntry[];
/** Maps userId → display name */
commissionerMap: Record<string, string | null>;
currentUserId: string | null;
/** Used to determine whether a commissioner also has a team in the league. */
teams: Array<{ id: string; ownerId: string | null }>;
activityEntries: AuditLogEntry[];
/** URL for the full audit log — shown only when there are activity entries. */
auditLogUrl: string;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function CommissionerRow({
name,
isSelf,
hasTeam,
}: {
name: string;
isSelf: boolean;
hasTeam: boolean;
}) {
return (
<div className="flex items-center gap-2 min-w-0 py-1.5 border-b last:border-0">
<p className="font-medium truncate min-w-0 flex-1">
{name}
{isSelf && (
<span className="ml-2 text-xs text-primary">(You)</span>
)}
</p>
{!hasTeam && (
<p className="text-xs text-muted-foreground shrink-0">No team</p>
)}
</div>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function CommissionersPanel({
commissioners,
commissionerMap,
currentUserId,
teams,
activityEntries,
auditLogUrl,
}: CommissionersPanelProps) {
return (
<div className="space-y-14">
{/* Commissioners */}
<div>
<div className="flex items-center gap-2 mb-4">
<GradientIcon icon={Crown} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Commissioners</span>
</div>
<div>
{commissioners.map((commissioner) => (
<CommissionerRow
key={commissioner.id}
name={commissionerMap[commissioner.userId] || "Unknown User"}
isSelf={commissioner.userId === currentUserId}
hasTeam={teams.some((t) => t.ownerId === commissioner.userId)}
/>
))}
</div>
</div>
{/* Commish Activity */}
{activityEntries.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<GradientIcon icon={Activity} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Commish Activity</span>
</div>
<Link
to={auditLogUrl}
className="flex items-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View All
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
<ul className="space-y-3">
{activityEntries.map((entry) => (
<li key={entry.id} className="flex items-start gap-3 text-sm">
<span className="text-muted-foreground whitespace-nowrap shrink-0">
{format(new Date(entry.createdAt), "MMM d, HH:mm")}
</span>
<span className="min-w-0">
<span className="font-medium">
{entry.actorDisplayName ?? entry.actorClerkId}
</span>
{" — "}
<span className="text-muted-foreground">
{formatAuditDetail(entry)}
</span>
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CreateLeagueCard } from "./CreateLeagueCard";
const meta: Meta<typeof CreateLeagueCard> = {
title: "League/CreateLeagueCard",
component: CreateLeagueCard,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof CreateLeagueCard>;
export const Default: Story = {};

View file

@ -0,0 +1,21 @@
import { Swords } from "lucide-react";
import { Link } from "react-router";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
export function CreateLeagueCard() {
return (
<Card className="gap-2">
<SectionCardHeader icon={Swords} title="Start a New League" />
<CardContent className="px-3 sm:px-6">
<p className="text-sm text-muted-foreground mb-4">
Invite friends, pick your sports, and start drafting.
</p>
<Button asChild className="w-full">
<Link to="/leagues/new">Create a League</Link>
</Button>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,145 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DraftInfoCard } from "./DraftInfoCard";
const meta: Meta<typeof DraftInfoCard> = {
title: "League/DraftInfoCard",
component: DraftInfoCard,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DraftInfoCard>;
const base = {
draftRounds: 8,
draftTimerMode: "chess_clock" as const,
draftInitialTime: 120, // 2:00 bank
draftIncrementTime: 15, // +0:15 per pick
sportsCount: 3,
isDraftOrderSet: false,
isDraftOrPreDraft: true,
draftBoardHref: "/leagues/1/draft-board/1",
draftRoomHref: "/leagues/1/draft/1",
};
export const PreDraftInProgress: Story = {
args: {
...base,
draftDateTime: new Date("2025-06-14T19:00:00"),
},
};
export const PreDraftOrderSet: Story = {
args: {
...base,
isDraftOrderSet: true,
draftDateTime: new Date("2025-06-14T19:00:00"),
},
};
export const PreDraftNoDate: Story = {
args: {
...base,
},
};
export const StandardTimer: Story = {
args: {
...base,
draftTimerMode: "standard",
draftInitialTime: 90,
draftIncrementTime: 90,
isDraftOrderSet: true,
draftDateTime: new Date("2025-06-14T19:00:00"),
},
};
export const StandardTimerLong: Story = {
name: "Standard Timer (slow league — 1 day/pick)",
args: {
...base,
draftTimerMode: "standard",
draftInitialTime: 86400,
draftIncrementTime: 86400,
isDraftOrderSet: true,
draftDateTime: new Date("2025-06-14T19:00:00"),
},
};
export const ChessClockSlow: Story = {
name: "Chess Clock (slow — 8h bank, 1h increment)",
args: {
...base,
draftInitialTime: 28800,
draftIncrementTime: 3600,
isDraftOrderSet: true,
draftDateTime: new Date("2025-06-14T19:00:00"),
},
};
export const WithDraftPosition: Story = {
name: "4 panels — draft position set (pick 3)",
args: {
...base,
isDraftOrderSet: true,
draftDateTime: new Date("2025-06-14T19:00:00"),
userDraftPosition: 3,
},
};
export const NonCommissionerView: Story = {
name: "Non-commissioner (no Set Order CTA)",
args: {
...base,
draftDateTime: new Date("2025-06-14T19:00:00"),
},
};
export const ActiveSeason: Story = {
name: "Active season — bare historical view",
args: {
draftRounds: 8,
draftTimerMode: "chess_clock",
draftInitialTime: 120,
draftIncrementTime: 15,
sportsCount: 3,
isDraftOrderSet: true,
isDraftOrPreDraft: false,
draftDateTime: new Date("2025-03-01T14:00:00"),
draftBoardHref: "/leagues/1/draft-board/1",
draftRoomHref: "/leagues/1/draft/1",
},
};
export const ActiveSeasonStandard: Story = {
name: "Active season — bare, standard timer",
args: {
draftRounds: 10,
draftTimerMode: "standard",
draftInitialTime: 90,
draftIncrementTime: 90,
sportsCount: 6,
isDraftOrderSet: true,
isDraftOrPreDraft: false,
draftDateTime: new Date("2025-03-01T14:00:00"),
draftBoardHref: "/leagues/1/draft-board/1",
draftRoomHref: "/leagues/1/draft/1",
},
};
export const ActiveSeasonNoDate: Story = {
name: "Active season — bare, no draft date",
args: {
draftRounds: 8,
draftTimerMode: "chess_clock",
draftInitialTime: 120,
draftIncrementTime: 15,
sportsCount: 3,
isDraftOrderSet: true,
isDraftOrPreDraft: false,
draftBoardHref: "/leagues/1/draft-board/1",
draftRoomHref: "/leagues/1/draft/1",
},
};

View file

@ -0,0 +1,208 @@
import { format } from "date-fns";
import { ChevronRight, ChessPawn, Hourglass, LayoutGrid } from "lucide-react";
import { Link } from "react-router";
import { formatClockTime } from "~/lib/draft-timer";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DraftInfoCardProps {
draftRounds: number;
draftTimerMode: "standard" | "chess_clock";
/** Per-pick time for standard; initial bank for chess clock (seconds) */
draftInitialTime: number;
/** Bonus seconds added after each pick (chess clock only; ignored in standard) */
draftIncrementTime: number;
sportsCount: number;
isDraftOrderSet: boolean;
/** true when season status is pre_draft or draft */
isDraftOrPreDraft: boolean;
draftDateTime?: string | Date | null;
draftBoardHref: string;
draftRoomHref: string;
/** 1-based draft position for the current user; omit or undefined to hide the panel */
userDraftPosition?: number;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function InfoPanel({ label, value, sub }: { label: string; value: React.ReactNode; sub?: React.ReactNode }) {
return (
<div className="flex flex-col rounded-lg bg-white/[0.04] px-3 py-3 gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground leading-none">
{label}
</span>
<span className="text-xl font-bold leading-none">{value}</span>
{sub && <span className="text-xs text-muted-foreground leading-none">{sub}</span>}
</div>
);
}
// ─── Card variant (pre_draft / draft) ─────────────────────────────────────────
function DraftInfoCardVariant({
draftRounds,
draftTimerMode,
draftInitialTime,
draftIncrementTime,
sportsCount,
isDraftOrderSet,
draftDateTime,
draftRoomHref,
userDraftPosition,
}: DraftInfoCardProps) {
const flexSpots = Math.max(0, draftRounds - sportsCount);
const timerLabel = draftTimerMode === "chess_clock" ? "Chess Clock" : "Standard Timer";
const timerValue = formatClockTime(draftInitialTime);
const timerSub = draftTimerMode === "chess_clock"
? `+${formatClockTime(draftIncrementTime)} per pick`
: "per pick";
const roundsSub = [
`${sportsCount} sport${sportsCount !== 1 ? "s" : ""}`,
flexSpots > 0 ? `${flexSpots} flex` : null,
].filter(Boolean).join(" + ");
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GradientIcon icon={LayoutGrid} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">Draft Info</h2>
</div>
{isDraftOrderSet && (
<Button asChild>
<Link to={draftRoomHref}>Enter Draft Room</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className={`grid gap-2 ${userDraftPosition !== null && userDraftPosition !== undefined ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}>
{/* Draft Date */}
<InfoPanel
label="Draft Date"
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
/>
{/* Rounds */}
<InfoPanel label="Rounds" value={draftRounds} sub={roundsSub} />
{/* User's Draft Position (only when set) */}
{userDraftPosition !== null && userDraftPosition !== undefined && (
<InfoPanel label="Your Pick" value={`#${userDraftPosition}`} sub="round 1 position" />
)}
{/* Timer */}
<InfoPanel
label={timerLabel}
value={
<span className="flex items-center gap-1.5">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-4 w-4 shrink-0 text-muted-foreground" />
: <Hourglass className="h-4 w-4 shrink-0 text-muted-foreground" />}
{timerValue}
</span>
}
sub={timerSub}
/>
</div>
</CardContent>
</Card>
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Converts a seconds value to a short human-readable string, e.g. "1 minute", "10 seconds", "2 hours". */
function formatHumanTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts: string[] = [];
if (h > 0) parts.push(`${h} hour${h !== 1 ? "s" : ""}`);
if (m > 0) parts.push(`${m} minute${m !== 1 ? "s" : ""}`);
if (s > 0) parts.push(`${s} second${s !== 1 ? "s" : ""}`);
return parts.join(" ") || "0 seconds";
}
// ─── Bare variant (active / completed) ────────────────────────────────────────
function DraftInfoBareVariant({
draftRounds,
draftTimerMode,
draftInitialTime,
draftIncrementTime,
sportsCount,
draftDateTime,
draftBoardHref,
}: DraftInfoCardProps) {
const flexSpots = Math.max(0, draftRounds - sportsCount);
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<GradientIcon icon={LayoutGrid} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Draft</span>
</div>
<Link
to={draftBoardHref}
className="flex items-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View Draft Board
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex gap-3">
{/* Mode icon */}
<div className="flex size-16 shrink-0 items-center justify-center bg-black">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-6 w-6 text-white" />
: <Hourglass className="h-6 w-6 text-white" />}
</div>
<div className="min-w-0">
{draftDateTime && (
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
<span className="text-xs font-semibold uppercase tracking-wide text-electric">
{format(new Date(draftDateTime), "MMM d, yyyy")}
</span>
<span className="text-border">|</span>
<span className="text-xs text-muted-foreground">
{format(new Date(draftDateTime), "p")}
</span>
</div>
)}
<div className="text-sm font-bold uppercase tracking-wide leading-tight">
{draftRounds} rounds ({sportsCount} sport{sportsCount !== 1 ? "s" : ""}
{flexSpots > 0 && ` + ${flexSpots} flex`})
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{draftTimerMode === "standard"
? `Standard — ${formatHumanTime(draftInitialTime)}/pick`
: `Chess Clock — ${formatHumanTime(draftInitialTime)} + ${formatHumanTime(draftIncrementTime)} increment`}
</div>
</div>
</div>
</div>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function DraftInfoCard(props: DraftInfoCardProps) {
if (props.isDraftOrPreDraft) {
return <DraftInfoCardVariant {...props} />;
}
return <DraftInfoBareVariant {...props} />;
}

View file

@ -0,0 +1,52 @@
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;
}
interface LeagueAvatarProps {
leagueId: string;
leagueName: string;
/** Tailwind size classes, e.g. "h-10 w-10" (default) or "h-5 w-5" */
className?: string;
/** Font size class, e.g. "text-sm" (default) or "text-[10px]" */
textClassName?: string;
}
export function LeagueAvatar({
leagueId,
leagueName,
className = "h-10 w-10",
textClassName = "text-sm",
}: LeagueAvatarProps) {
const color = AVATAR_COLORS[hashLeagueId(leagueId) % AVATAR_COLORS.length];
const initials =
leagueName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
return (
<div
role="img"
aria-label={leagueName}
className={`flex shrink-0 items-center justify-center rounded-none font-bold ${className} ${textClassName}`}
style={{ backgroundColor: "#000", color }}
>
{initials}
</div>
);
}

View file

@ -0,0 +1,99 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LeagueRow } from "./LeagueRow";
const meta: Meta<typeof LeagueRow> = {
title: "League/LeagueRow",
component: LeagueRow,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof LeagueRow>;
const baseLeague = {
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
numSports: 2,
};
export const DraftInProgress: Story = {
args: {
...baseLeague,
status: "draft",
seasonId: "season-xyz-456",
},
};
export const ActiveRankUp: Story = {
args: {
...baseLeague,
leagueName: "Hardcourt Elites",
leagueId: "league-hce-999",
numSports: 1,
status: "active",
currentRank: 4,
previousRank: 6,
totalPoints: 1422.8,
},
};
export const ActiveRankDown: Story = {
args: {
...baseLeague,
leagueName: "Monday Night Madness",
leagueId: "league-mnm-777",
numSports: 3,
status: "active",
currentRank: 7,
previousRank: 5,
totalPoints: 987.5,
},
};
export const ActiveNoChange: Story = {
args: {
...baseLeague,
leagueName: "The Bracket Boys",
leagueId: "league-tbb-555",
numSports: 2,
status: "active",
currentRank: 1,
previousRank: 1,
totalPoints: 2810.0,
},
};
export const ActiveNoStanding: Story = {
args: {
...baseLeague,
leagueName: "Fresh Start",
leagueId: "league-fs-222",
numSports: 2,
status: "active",
},
};
export const PreDraft: Story = {
args: {
...baseLeague,
leagueName: "Summer Smash League",
leagueId: "league-ssl-333",
numSports: 4,
status: "pre_draft",
seasonId: "season-ssl-999",
},
};
export const Completed: Story = {
args: {
...baseLeague,
leagueName: "2023 World Tour",
leagueId: "league-wt-111",
numSports: 2,
status: "completed",
currentRank: 3,
totalPoints: 3150.2,
},
};

View file

@ -0,0 +1,250 @@
import { formatDistanceToNow } from "date-fns";
import { Link } from "react-router";
import { Button } from "~/components/ui/button";
import { LeagueAvatar } from "./LeagueAvatar";
export interface LeagueRowProps {
leagueId: string;
leagueName: string;
numSports: number;
status: "draft" | "active" | "pre_draft" | "completed";
seasonId?: string;
currentRank?: number;
totalPoints?: number;
previousRank?: number;
completionPercentage?: number;
draftDateTime?: string | null;
picksUntilMyTurn?: number;
draftPosition?: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function ordinal(n: number): string {
const v = n % 100;
// 1113 are always "th" (e.g. 11th, 12th, 13th)
if (v >= 11 && v <= 13) return `${n}th`;
const s = ["th", "st", "nd", "rd"];
return `${n}${s[n % 10] ?? "th"}`;
}
// ─── Shared stat column ───────────────────────────────────────────────────────
function StatColumn({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
<div className="flex items-baseline justify-end gap-1">{children}</div>
</div>
);
}
function StatDivider() {
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
}
// ─── Season progress bar ──────────────────────────────────────────────────────
function SeasonProgress({ pct, status }: { pct: number; status: "active" | "completed" }) {
const label = status === "completed" ? "100% Complete" : `${pct}% Complete`;
const fill = status === "completed" ? 100 : pct;
return (
<div className="flex items-center gap-1.5 mt-1.5 w-40">
<div className="flex-1 h-1 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-electric transition-all"
style={{ width: `${fill}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
</div>
);
}
// ─── Rank change indicator ────────────────────────────────────────────────────
function RankChange({ current, previous }: { current: number; previous: number | undefined }) {
if (previous === undefined || previous === current) return null;
const delta = previous - current;
if (delta > 0) {
return <span className="text-xs font-semibold text-primary" aria-label={`ranked up ${delta}`}>{delta}</span>;
}
return (
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }} aria-label={`ranked down ${Math.abs(delta)}`}>
{Math.abs(delta)}
</span>
);
}
// ─── Row variants ─────────────────────────────────────────────────────────────
function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) {
const draftUrl = `/leagues/${leagueId}/draft/${seasonId}`;
const picksLabel =
picksUntilMyTurn === 0
? "You're on the clock!"
: picksUntilMyTurn !== undefined
? `Up in ${picksUntilMyTurn} pick${picksUntilMyTurn === 1 ? "" : "s"}`
: null;
return (
<div className="flex items-start gap-3 rounded-lg border border-primary/40 bg-primary/10 px-3 py-3 sm:px-5 sm:py-4">
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-electric" />
<span className="text-xs font-semibold tracking-wide text-electric uppercase">
Draft in Progress
</span>
</div>
{picksLabel && (
<span className="text-xs text-muted-foreground">{picksLabel}</span>
)}
</div>
{seasonId && (
<Button asChild size="sm" className="mt-3 sm:hidden">
<Link to={draftUrl}>Enter Draft</Link>
</Button>
)}
</div>
{seasonId && (
<Button asChild size="sm" className="shrink-0 hidden sm:inline-flex">
<Link to={draftUrl}>Enter Draft</Link>
</Button>
)}
</div>
);
}
function ActiveRow({
leagueId,
leagueName,
currentRank,
totalPoints,
previousRank,
completionPercentage = 0,
}: LeagueRowProps) {
const showStats = currentRank !== undefined || totalPoints !== undefined;
return (
<Link
to={`/leagues/${leagueId}`}
className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
>
{/* Avatar + name */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<SeasonProgress pct={completionPercentage} status="active" />
</div>
</div>
{/* Stats — second row on mobile, right side on desktop */}
{showStats && (
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
{currentRank !== undefined && (
<StatColumn label="Ranking">
<span className="text-2xl font-bold leading-none">#{currentRank}</span>
<RankChange current={currentRank} previous={previousRank} />
</StatColumn>
)}
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
{totalPoints !== undefined && (
<StatColumn label="Points">
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(totalPoints).toLocaleString("en-US")}
</span>
</StatColumn>
)}
</div>
)}
</Link>
);
}
function PreDraftRow({
leagueId,
leagueName,
draftDateTime,
draftPosition,
}: LeagueRowProps) {
let draftTimeValue = "Not scheduled";
if (draftDateTime) {
const draftDate = new Date(draftDateTime);
draftTimeValue =
draftDate > new Date()
? formatDistanceToNow(draftDate, { addSuffix: true })
: "Starting soon";
}
return (
<Link
to={`/leagues/${leagueId}`}
className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
>
{/* Avatar + name */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<p className="text-xs text-muted-foreground mt-0.5">Pre-Draft</p>
</div>
</div>
{/* Stats — second row on mobile, right side on desktop */}
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
<StatColumn label="Draft">
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
</StatColumn>
{draftPosition !== undefined && (
<>
<StatDivider />
<StatColumn label="Position">
<span className="text-2xl font-bold leading-none">
{ordinal(draftPosition)}
</span>
</StatColumn>
</>
)}
</div>
</Link>
);
}
function CompletedRow({
leagueId,
leagueName,
completionPercentage = 0,
}: LeagueRowProps) {
return (
<Link
to={`/leagues/${leagueId}`}
className="flex items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
>
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<SeasonProgress pct={completionPercentage} status="completed" />
</div>
</Link>
);
}
// ─── Public export ────────────────────────────────────────────────────────────
export function LeagueRow(props: LeagueRowProps) {
if (props.status === "draft") return <DraftRow {...props} />;
if (props.status === "active") return <ActiveRow {...props} />;
if (props.status === "pre_draft") return <PreDraftRow {...props} />;
return <CompletedRow {...props} />;
}

View file

@ -0,0 +1,96 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MyLeaguesCard } from "./MyLeaguesCard";
const meta: Meta<typeof MyLeaguesCard> = {
title: "League/MyLeaguesCard",
component: MyLeaguesCard,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof MyLeaguesCard>;
export const MixedStatuses: Story = {
args: {
leagues: [
{
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
numSports: 2,
status: "active",
currentRank: 4,
previousRank: 6,
totalPoints: 1422.8,
},
{
leagueId: "league-hce-999",
leagueName: "Hardcourt Elites",
numSports: 1,
status: "draft",
seasonId: "season-xyz-456",
},
{
leagueId: "league-ssl-333",
leagueName: "Summer Smash",
numSports: 3,
status: "pre_draft",
},
{
leagueId: "league-wt-111",
leagueName: "2023 World Tour",
numSports: 2,
status: "completed",
currentRank: 2,
totalPoints: 3150.2,
},
],
},
};
export const DraftingOnly: Story = {
args: {
leagues: [
{
leagueId: "league-abc-123",
leagueName: "World Aquatics Open",
numSports: 2,
status: "draft",
seasonId: "season-wao-789",
},
],
},
};
export const ActiveOnly: Story = {
args: {
leagues: [
{
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
numSports: 2,
status: "active",
currentRank: 4,
previousRank: 6,
totalPoints: 1422.8,
},
{
leagueId: "league-hce-999",
leagueName: "Hardcourt Elites",
numSports: 1,
status: "active",
currentRank: 12,
previousRank: 11,
totalPoints: 2810.0,
},
],
viewAllUrl: "/leagues",
},
};
export const Empty: Story = {
args: {
leagues: [],
},
};

View file

@ -0,0 +1,64 @@
import { Shield } from "lucide-react";
import { Link } from "react-router";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
import { LeagueRow, type LeagueRowProps } from "./LeagueRow";
const STATUS_ORDER: Record<LeagueRowProps["status"], number> = {
draft: 0,
active: 1,
pre_draft: 2,
completed: 3,
};
interface MyLeaguesCardProps {
leagues: LeagueRowProps[];
viewAllUrl?: string;
}
export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) {
const sorted = leagues.toSorted((a, b) => {
const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
if (statusDiff !== 0) return statusDiff;
if (a.status === "active") {
return (b.completionPercentage ?? 0) - (a.completionPercentage ?? 0);
}
return 0;
});
const right = viewAllUrl ? (
<Link
to={viewAllUrl}
className="text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View All Leagues
</Link>
) : undefined;
return (
<Card className="bg-[#181b22] gap-2">
<SectionCardHeader icon={Shield} title="My Leagues" right={right} />
<CardContent className="px-3 sm:px-6">
{sorted.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-muted-foreground mb-3">
You aren&apos;t a member of any leagues yet.
</p>
<Link
to="/leagues/new"
className="text-sm font-medium text-primary hover:underline"
>
Create your first league
</Link>
</div>
) : (
<div className="space-y-3">
{sorted.map((league) => (
<LeagueRow key={league.leagueId} {...league} />
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,230 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SportsSeasonsSummary } from "./SportsSeasonsSummary";
import type { SportSeasonSummaryItem } from "./SportsSeasonsSummary";
const meta: Meta<typeof SportsSeasonsSummary> = {
title: "League/SportsSeasonsSummary",
component: SportsSeasonsSummary,
parameters: {
layout: "padded",
},
args: {
leagueId: "league-abc-123",
},
};
export default meta;
type Story = StoryObj<typeof SportsSeasonsSummary>;
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const nba: SportSeasonSummaryItem = {
id: "ss-nba",
sportName: "NBA",
seasonName: "2025 NBA Season",
status: "active",
scoringPattern: "playoff_bracket",
draftedParticipants: [
{ id: "p1", name: "LeBron James", earnedPoints: null, currentQP: null },
{ id: "p2", name: "Jayson Tatum", earnedPoints: 62.5, currentQP: null },
],
upcomingParticipantEvents: [
{
id: "ev-nba-1",
name: "Conference Semifinals",
matchLabel: "Game 2",
eventDate: null,
earliestGameTime: "2026-04-20T19:30:00.000Z",
eventType: "playoff_game",
sportsSeasonId: "ss-nba",
relevantParticipants: [{ id: "p1", name: "LeBron James" }],
},
],
};
const f1: SportSeasonSummaryItem = {
id: "ss-f1",
sportName: "F1",
seasonName: "2025 Formula 1 World Championship",
status: "active",
scoringPattern: "season_standings",
draftedParticipants: [
{ id: "p3", name: "Max Verstappen", earnedPoints: 250, currentQP: null },
{ id: "p4", name: "Charles Leclerc", earnedPoints: 125, currentQP: null },
{ id: "p5", name: "Fernando Alonso", earnedPoints: 62.5, currentQP: null },
{ id: "p6", name: "Lewis Hamilton", earnedPoints: 62.5, currentQP: null },
],
upcomingParticipantEvents: [
{
id: "ev-f1-1",
name: "Monaco Grand Prix",
matchLabel: null,
eventDate: "2026-05-25",
earliestGameTime: null,
eventType: "race",
sportsSeasonId: "ss-f1",
relevantParticipants: [
{ id: "p3", name: "Max Verstappen" },
{ id: "p4", name: "Charles Leclerc" },
{ id: "p5", name: "Fernando Alonso" },
{ id: "p6", name: "Lewis Hamilton" },
],
},
],
};
const golf: SportSeasonSummaryItem = {
id: "ss-golf",
sportName: "Golf",
seasonName: "2025 Major Season",
status: "active",
scoringPattern: "qualifying_points",
draftedParticipants: [
{ id: "p7", name: "Scottie Scheffler", earnedPoints: null, currentQP: 550 },
{ id: "p8", name: "Rory McIlroy", earnedPoints: null, currentQP: 325 },
],
upcomingParticipantEvents: [
{
id: "ev-golf-1",
name: "US Open",
matchLabel: null,
eventDate: "2026-06-15",
earliestGameTime: null,
eventType: "tournament",
sportsSeasonId: "ss-golf",
relevantParticipants: [
{ id: "p7", name: "Scottie Scheffler" },
{ id: "p8", name: "Rory McIlroy" },
],
},
],
};
const darts: SportSeasonSummaryItem = {
id: "ss-darts",
sportName: "Darts",
seasonName: "PDC World Darts Championship 2025",
status: "completed",
scoringPattern: "playoff_bracket",
draftedParticipants: [
{ id: "p9", name: "Michael van Gerwen", earnedPoints: 250, currentQP: null },
],
upcomingParticipantEvents: [],
};
const snooker: SportSeasonSummaryItem = {
id: "ss-snooker",
sportName: "Snooker",
seasonName: "2025 World Snooker Championship",
status: "upcoming",
scoringPattern: "playoff_bracket",
draftedParticipants: [
{ id: "p10", name: "Ronnie O'Sullivan", earnedPoints: null, currentQP: null },
{ id: "p11", name: "Judd Trump", earnedPoints: null, currentQP: null },
],
upcomingParticipantEvents: [],
};
const nhl: SportSeasonSummaryItem = {
id: "ss-nhl",
sportName: "NHL",
seasonName: "2024-25 NHL Season",
status: "active",
scoringPattern: "season_standings",
draftedParticipants: [
{ id: "p12", name: "Connor McDavid", earnedPoints: null, currentQP: null },
{ id: "p13", name: "Nathan MacKinnon", earnedPoints: 125, currentQP: null },
{ id: "p14", name: "David Pastrnak", earnedPoints: 62.5, currentQP: null },
],
upcomingParticipantEvents: [
{
id: "ev-nhl-1",
name: "Playoffs Round 1",
matchLabel: "Game 4",
eventDate: null,
earliestGameTime: "2026-04-21T23:00:00.000Z",
eventType: "playoff_game",
sportsSeasonId: "ss-nhl",
relevantParticipants: [{ id: "p12", name: "Connor McDavid" }],
},
],
};
// ─── Stories ──────────────────────────────────────────────────────────────────
export const AllStatuses: Story = {
args: {
sportsSeasons: [nba, golf, snooker, darts],
},
};
export const ActiveWithPoints: Story = {
name: "Active Sports — Mix of earned + pending points",
args: {
sportsSeasons: [nba, f1, nhl],
},
};
export const QualifyingPoints: Story = {
name: "Qualifying Points Sport (Golf)",
args: {
sportsSeasons: [golf],
},
};
export const WithViewAllLink: Story = {
args: {
sportsSeasons: [nba, f1, golf, snooker, darts],
allSeasonsHref: "/leagues/league-abc-123/sports-seasons",
},
};
export const ManyParticipants: Story = {
name: "Many Participants (overflow chip)",
args: {
sportsSeasons: [f1],
},
};
export const NoParticipants: Story = {
name: "No Drafted Participants (viewer not in league)",
args: {
sportsSeasons: [nba, snooker, darts].map((ss) => ({ ...ss, draftedParticipants: [] })),
},
};
export const CompletedWithPoints: Story = {
name: "Completed — Points Earned",
args: {
sportsSeasons: [
darts,
{ ...snooker, status: "completed" as const, draftedParticipants: [
{ id: "p10", name: "Ronnie O'Sullivan", earnedPoints: 125, currentQP: null },
]},
],
},
};
export const LargeLeague: Story = {
name: "Large League (6 sports)",
args: {
sportsSeasons: [nba, f1, nhl, golf, snooker, darts],
allSeasonsHref: "/leagues/league-abc-123/sports-seasons",
},
};
export const LongNames: Story = {
args: {
sportsSeasons: [
{
...f1,
sportName: "UEFA Champions League European Football",
seasonName: "2024/25 Group Stage through Final — Extended Edition",
draftedParticipants: [
{ id: "p3", name: "Erling Braut Haaland", earnedPoints: 250, currentQP: null },
{ id: "p4", name: "Vinicius José de Oliveira Junior", earnedPoints: 125, currentQP: null },
],
},
],
},
};

View file

@ -0,0 +1,285 @@
import { format } from "date-fns";
import { Calendar, CheckCircle2, ChevronRight, Clock, Layers, ListOrdered, SquareStack, Trophy } from "lucide-react";
import { Link } from "react-router";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon";
import type { UpcomingParticipantEvent } from "~/models/scoring-event";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DraftedParticipantSummary {
id: string;
name: string;
/** Fantasy league points earned (position → scoring rules). null = no result yet. */
earnedPoints: number | null;
/** Accumulated qualifying points for qualifying_points sports. null for other sports. */
currentQP: number | null;
}
export interface SportSeasonSummaryItem {
id: string;
sportName: string;
seasonName: string;
status: "upcoming" | "active" | "completed";
scoringPattern?: string | null;
upcomingParticipantEvents?: UpcomingParticipantEvent[];
/** All participants the current user drafted for this sport, with points data. */
draftedParticipants?: DraftedParticipantSummary[];
}
export interface SportsSeasonsSummaryProps {
leagueId: string;
sportsSeasons: SportSeasonSummaryItem[];
/** Link to the full sports seasons list, if one exists. */
allSeasonsHref?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const STATUS_ORDER: Record<SportSeasonSummaryItem["status"], number> = {
active: 0,
upcoming: 1,
completed: 2,
};
function sortedByStatus(items: SportSeasonSummaryItem[]): SportSeasonSummaryItem[] {
return [...items].toSorted((a, b) => {
const diff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
if (diff !== 0) return diff;
return a.sportName.localeCompare(b.sportName);
});
}
const PATTERN_ICON: Record<string, { icon: typeof ChevronRight; title: string }> = {
playoff_bracket: { icon: ChevronRight, title: "Bracket" },
season_standings: { icon: ListOrdered, title: "Season Standings" },
qualifying_points: { icon: SquareStack, title: "Qualifying Points" },
};
function SportIcon({ pattern }: { pattern?: string | null }) {
const entry = pattern ? PATTERN_ICON[pattern] : null;
const Icon = entry?.icon ?? Trophy;
return (
<div
className="h-9 w-9 shrink-0 flex items-center justify-center bg-black"
title={entry?.title}
>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
/** Format the next upcoming event into a short string. */
function formatNextEvent(event: UpcomingParticipantEvent): string {
const base = event.matchLabel
? `${event.name}${event.matchLabel}`
: event.name;
const date = event.earliestGameTime
? format(new Date(event.earliestGameTime), "MMM d")
: event.eventDate
? format(new Date(event.eventDate + "T00:00:00"), "MMM d")
: null;
return date ? `${base} · ${date}` : base;
}
function formatPoints(p: DraftedParticipantSummary, scoringPattern?: string | null): string | null {
if (scoringPattern === "qualifying_points" && p.earnedPoints === null) {
if (!p.currentQP) return null;
return `${p.currentQP % 1 === 0 ? p.currentQP : p.currentQP.toFixed(1)} QP`;
}
if (p.earnedPoints === null) return null;
return `${Math.round(p.earnedPoints)} pts`;
}
// ─── Section header ───────────────────────────────────────────────────────────
type SectionStatus = "active" | "upcoming" | "completed";
function SectionHeader({ status }: { status: SectionStatus }) {
if (status === "active") {
return (
<div className="flex items-center gap-1.5 px-1 py-0.5">
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
<span className="text-xs font-semibold uppercase tracking-widest text-emerald-400">
Active
</span>
</div>
);
}
if (status === "upcoming") {
return (
<div className="flex items-center gap-1.5 px-1 py-0.5">
<Clock className="h-3 w-3 text-electric shrink-0" />
<span className="text-xs font-semibold uppercase tracking-widest text-electric">
Upcoming
</span>
</div>
);
}
return (
<div className="flex items-center gap-1.5 px-1 py-0.5">
<CheckCircle2 className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Completed
</span>
</div>
);
}
// ─── Participant chip ─────────────────────────────────────────────────────────
function ParticipantChip({
participant,
scoringPattern,
}: {
participant: DraftedParticipantSummary;
scoringPattern?: string | null;
}) {
const pointsLabel = formatPoints(participant, scoringPattern);
return (
<Badge variant="secondary" className="text-xs h-5 px-1.5 shrink-0 gap-1 bg-white/[0.1]">
<span>{participant.name}</span>
{pointsLabel && (
<span className="text-muted-foreground font-normal">· {pointsLabel}</span>
)}
</Badge>
);
}
// ─── Single sport row ─────────────────────────────────────────────────────────
function SportRow({
item,
leagueId,
}: {
item: SportSeasonSummaryItem;
leagueId: string;
}) {
const participants = item.draftedParticipants ?? [];
const events = item.upcomingParticipantEvents ?? [];
const nextEvent = events[0] ?? null;
const isCompleted = item.status === "completed";
const inner = (
<div
className={`group flex gap-3 rounded-lg px-3 py-2.5 transition-colors ${
isCompleted
? "hover:bg-white/[0.04] opacity-70 hover:opacity-100"
: "hover:bg-white/[0.06]"
}`}
>
{/* Left: scoring pattern icon in black box */}
<SportIcon pattern={item.scoringPattern} />
{/* Right: name + participants + next event */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm leading-tight truncate">{item.sportName}</p>
<p className="text-xs text-muted-foreground truncate mb-1.5">{item.seasonName}</p>
{participants.length > 0 && (
<div className="flex flex-wrap gap-1">
{participants.map((p) => (
<ParticipantChip key={p.id} participant={p} scoringPattern={item.scoringPattern} />
))}
</div>
)}
{nextEvent && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-1.5">
<Calendar className="h-3 w-3 text-electric shrink-0" />
<span className="truncate" suppressHydrationWarning>
{formatNextEvent(nextEvent)}
</span>
</div>
)}
</div>
</div>
);
return (
<Link
to={`/leagues/${leagueId}/sports-seasons/${item.id}`}
className="block"
aria-label={`${item.sportName}${item.seasonName}`}
>
{inner}
</Link>
);
}
// ─── Section ──────────────────────────────────────────────────────────────────
function Section({
status,
items,
leagueId,
}: {
status: SectionStatus;
items: SportSeasonSummaryItem[];
leagueId: string;
}) {
if (items.length === 0) return null;
return (
<div className="space-y-1">
<SectionHeader status={status} />
<div className="grid gap-0.5 sm:grid-cols-2">
{items.map((item) => (
<SportRow key={item.id} item={item} leagueId={leagueId} />
))}
</div>
</div>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function SportsSeasonsSummary({
leagueId,
sportsSeasons,
allSeasonsHref,
}: SportsSeasonsSummaryProps) {
const sorted = sortedByStatus(sportsSeasons);
const active = sorted.filter((s) => s.status === "active");
const upcoming = sorted.filter((s) => s.status === "upcoming");
const completed = sorted.filter((s) => s.status === "completed");
return (
<Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GradientIcon icon={Layers} className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-xl font-bold leading-none tracking-tight">Sports</h2>
</div>
</div>
{allSeasonsHref && (
<Button variant="outline" size="sm" asChild>
<Link to={allSeasonsHref}>View All</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-3 sm:px-6 space-y-3">
<Section status="active" items={active} leagueId={leagueId} />
{active.length > 0 && upcoming.length > 0 && (
<div className="border-t border-border/40" />
)}
<Section status="upcoming" items={upcoming} leagueId={leagueId} />
{(active.length > 0 || upcoming.length > 0) && completed.length > 0 && (
<div className="border-t border-border/40" />
)}
<Section status="completed" items={completed} leagueId={leagueId} />
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,265 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StandingsPreview } from "./StandingsPreview";
const meta: Meta<typeof StandingsPreview> = {
title: "League/StandingsPreview",
component: StandingsPreview,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof StandingsPreview>;
export const TopThreePodium: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: 1,
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: 2,
currentRank: 2,
points: 2654.5,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: 3,
currentRank: 3,
points: 2493,
href: "/leagues/1/standings/1/teams/t3",
},
],
},
};
export const FullLeague: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: 1,
currentRank: 1,
points: 2810,
rankChange: 2,
pointChange: 87.5,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: 2,
currentRank: 2,
points: 2654.5,
rankChange: 0,
pointChange: 42.0,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: 3,
currentRank: 3,
points: 2493,
rankChange: -1,
pointChange: -12.3,
href: "/leagues/1/standings/1/teams/t3",
},
{
teamId: "t4",
teamName: "Cyber Foxes",
ownerName: "dave",
displayRank: 4,
currentRank: 4,
points: 2311.8,
rankChange: 1,
pointChange: 23.1,
href: "/leagues/1/standings/1/teams/t4",
},
{
teamId: "t5",
teamName: "Neon Tigers",
ownerName: "eve",
displayRank: 5,
currentRank: 5,
points: 2108,
rankChange: -2,
pointChange: -55.0,
href: "/leagues/1/standings/1/teams/t5",
},
{
teamId: "t6",
teamName: "Phantom Sharks",
ownerName: "frank",
displayRank: 6,
currentRank: 6,
points: 1987.3,
rankChange: 0,
pointChange: 0,
href: "/leagues/1/standings/1/teams/t6",
},
{
teamId: "t7",
teamName: "Crimson Tide FC",
ownerName: "grace",
displayRank: 7,
currentRank: 7,
points: 1834,
href: "/leagues/1/standings/1/teams/t7",
},
{
teamId: "t8",
teamName: "Arctic Wolves United",
ownerName: "hank",
displayRank: 8,
currentRank: 8,
points: 1692.6,
href: "/leagues/1/standings/1/teams/t8",
},
],
},
};
export const WithTies: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: "T1",
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: "T1",
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: "T3",
currentRank: 3,
points: 2493,
href: "/leagues/1/standings/1/teams/t3",
},
{
teamId: "t4",
teamName: "Cyber Foxes",
ownerName: "dave",
displayRank: "T3",
currentRank: 3,
points: 2493,
href: "/leagues/1/standings/1/teams/t4",
},
],
},
};
export const AllUnranked: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: "T1",
points: 0,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: "T1",
points: 0,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: "T1",
points: 0,
href: "/leagues/1/standings/1/teams/t3",
},
],
},
};
export const NoOwnerNames: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
displayRank: 1,
currentRank: 1,
points: 2810,
},
{
teamId: "t2",
teamName: "Shadow Hawks",
displayRank: 2,
currentRank: 2,
points: 2654.5,
},
{
teamId: "t3",
teamName: "Iron Eagles",
displayRank: 3,
currentRank: 3,
points: 2493,
},
],
},
};
export const LongTeamNames: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "The Unstoppable Championship Winning Lightning Wolves",
ownerName: "alice_with_a_really_long_username",
displayRank: 1,
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks of the Northern Division",
ownerName: "bob",
displayRank: 2,
currentRank: 2,
points: 2654.5,
href: "/leagues/1/standings/1/teams/t2",
},
],
},
};

View file

@ -0,0 +1,180 @@
import { ListOrdered } from "lucide-react";
import { Link } from "react-router";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { TeamAvatar } from "~/components/TeamAvatar";
// ─── Stat helpers (mirrors LeagueRow) ────────────────────────────────────────
function StatColumn({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
<div className="flex items-baseline justify-end gap-1">{children}</div>
</div>
);
}
function StatDivider() {
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
}
function RankChangeIndicator({ change }: { change: number }) {
if (change === 0) return null;
if (change > 0) {
return (
<span className="text-xs font-semibold text-primary" aria-label={`up ${change}`}>
{change}
</span>
);
}
return (
<span
className="text-xs font-semibold"
style={{ color: "var(--coral-accent, #ef4444)" }}
aria-label={`down ${Math.abs(change)}`}
>
{Math.abs(change)}
</span>
);
}
function PointChangeIndicator({ change }: { change: number }) {
if (change >= 0) {
return <span className="text-xs font-semibold text-primary">{Math.round(change)}</span>;
}
return (
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }}>
{Math.abs(Math.round(change))}
</span>
);
}
// ─── Row styles ───────────────────────────────────────────────────────────────
const ROW_RANK_CLASSES: Record<number, string> = {
1: "bg-yellow-500/10 hover:bg-yellow-500/15",
2: "bg-white/[0.14] hover:bg-white/[0.18]",
3: "bg-orange-600/[0.08] hover:bg-orange-600/[0.12]",
};
function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
const podium = currentRank !== undefined ? ROW_RANK_CLASSES[currentRank] : undefined;
const base = podium ?? `bg-white/[0.04] ${hasHref ? "hover:bg-white/[0.07]" : ""}`;
return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${base}`;
}
// ─── Row content ──────────────────────────────────────────────────────────────
function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
return (
<>
{/* Left: avatar + name */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar teamId={entry.teamId} teamName={entry.teamName} />
<div className="min-w-0">
<p className="font-medium text-sm leading-tight truncate">{entry.teamName}</p>
{entry.ownerName && (
<p className="text-xs text-muted-foreground truncate">{entry.ownerName}</p>
)}
</div>
</div>
{/* Right: stats — second row on mobile */}
<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">
<StatColumn label="Ranking">
<span className="text-2xl font-bold leading-none">{entry.displayRank}</span>
{entry.rankChange !== undefined && entry.rankChange !== 0 && (
<RankChangeIndicator change={entry.rankChange} />
)}
</StatColumn>
<StatDivider />
<StatColumn label="Points">
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(entry.points).toLocaleString("en-US")}
</span>
{entry.pointChange !== undefined && entry.pointChange !== 0 && (
<PointChangeIndicator change={entry.pointChange} />
)}
</StatColumn>
</div>
</>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export interface StandingsPreviewEntry {
teamId: string;
teamName: string;
ownerName?: string | null;
/** Pre-computed display rank, e.g. 1, "T2", "T5". Use getDisplayRank(). */
displayRank: string | number;
/** Numeric rank used to select the gold/silver/bronze row tint (13 only). */
currentRank?: number;
points: number;
href?: string;
/** Positive = moved up, negative = moved down. From TeamStanding.rankChange. */
rankChange?: number;
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */
pointChange?: number;
}
export interface StandingsPreviewProps {
entries: StandingsPreviewEntry[];
description?: string;
fullStandingsHref?: string;
}
export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) {
return (
<Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GradientIcon icon={ListOrdered} className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-xl font-bold leading-none tracking-tight">Standings</h2>
{description && (
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
)}
</div>
</div>
{fullStandingsHref && (
<Button variant="outline" size="sm" asChild>
<Link to={fullStandingsHref}>Full Standings</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-3 sm:px-6">
<div className="space-y-3">
{entries.map((entry) =>
entry.href ? (
<Link
key={entry.teamId}
to={entry.href}
className={rowClasses(entry.currentRank, true)}
>
<RowContent entry={entry} />
</Link>
) : (
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
<RowContent entry={entry} />
</div>
)
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,94 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TeamsPanel } from "./TeamsPanel";
const meta: Meta<typeof TeamsPanel> = {
title: "League/TeamsPanel",
component: TeamsPanel,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof TeamsPanel>;
const teams = [
{ id: "t1", name: "Lightning Wolves", ownerId: "u1" },
{ id: "t2", name: "Shadow Hawks", ownerId: "u2" },
{ id: "t3", name: "Iron Eagles", ownerId: "u3" },
{ id: "t4", name: "Cyber Foxes", ownerId: null },
{ id: "t5", name: "Neon Tigers", ownerId: null },
{ id: "t6", name: "Phantom Sharks", ownerId: null },
{ id: "t7", name: "Crimson Tide FC", ownerId: null },
{ id: "t8", name: "Arctic Wolves", ownerId: null },
];
const ownerMap = {
u1: "alice",
u2: "bob",
u3: "carol",
};
const draftSlots = teams.map((t) => ({ team: { id: t.id } }));
// No draft order: only show claimed teams + progress bar
export const NoOrderPartialFill: Story = {
name: "No order — partial fill",
args: {
teams,
currentUserId: "u1",
ownerMap,
draftSlots: [],
},
};
export const NoOrderFull: Story = {
name: "No order — league full",
args: {
teams: teams.map((t, i) => ({ ...t, ownerId: `u${i + 1}` })),
currentUserId: "u1",
ownerMap: Object.fromEntries(teams.map((_, i) => [`u${i + 1}`, `user${i + 1}`])),
draftSlots: [],
},
};
// Draft order set: show all teams sorted, with position numbers
export const OrderSetPartialFill: Story = {
name: "Draft order set — partial fill (progress bar shown)",
args: {
teams,
currentUserId: "u1",
ownerMap,
draftSlots,
},
};
export const OrderSetFull: Story = {
name: "Draft order set — league full (no progress bar)",
args: {
teams: teams.map((t, i) => ({ ...t, ownerId: `u${i + 1}` })),
currentUserId: "u1",
ownerMap: Object.fromEntries(teams.map((_, i) => [`u${i + 1}`, `user${i + 1}`])),
draftSlots,
},
};
export const YourTeamHighlighted: Story = {
name: "Your team highlighted (pick 4)",
args: {
teams,
currentUserId: "u3",
ownerMap,
draftSlots,
},
};
export const NoOwnerNames: Story = {
name: "No owner names resolved",
args: {
teams,
currentUserId: null,
ownerMap: {},
draftSlots,
},
};

View file

@ -0,0 +1,89 @@
import { Users } from "lucide-react";
import { GradientIcon } from "~/components/ui/GradientIcon";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface TeamsPanelProps {
teams: Array<{ id: string; name: string; ownerId: string | null }>;
currentUserId: string | null;
/** Maps userId → display name */
ownerMap: Record<string, string | null>;
/** When non-empty, sorts all teams in draft order and shows position numbers */
draftSlots: Array<{ team: { id: string } }>;
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function TeamsPanel({ teams, currentUserId, ownerMap, draftSlots }: TeamsPanelProps) {
const hasDraftOrder = draftSlots.length > 0;
const claimedCount = teams.filter((t) => t.ownerId !== null).length;
const isFull = claimedCount === teams.length;
const fillPercent = teams.length > 0 ? Math.round((claimedCount / teams.length) * 100) : 0;
const displayTeams = hasDraftOrder
? (() => {
const orderMap = new Map(draftSlots.map((s, i) => [s.team.id, i]));
return teams.toSorted(
(a, b) => (orderMap.get(a.id) ?? Infinity) - (orderMap.get(b.id) ?? Infinity)
);
})()
: teams.filter((t) => t.ownerId !== null);
const showProgressBar = !(hasDraftOrder && isFull);
return (
<div>
<div className="flex items-center gap-2 mb-3">
<GradientIcon icon={Users} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">
{hasDraftOrder ? "Draft Order" : "Teams"}
</span>
</div>
{showProgressBar && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Filled
</span>
<span className="text-sm font-bold">
{claimedCount}
<span className="text-muted-foreground font-normal"> / {teams.length}</span>
</span>
</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"
style={{ width: `${fillPercent}%` }}
/>
</div>
</div>
)}
<div>
{displayTeams.map((team, index) => {
const isOwned = team.ownerId === currentUserId;
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div key={team.id} className="flex items-center gap-3 py-1.5 border-b last:border-0">
{hasDraftOrder && (
<span className="text-xs font-bold text-muted-foreground w-4 shrink-0 text-right">
{index + 1}
</span>
)}
<p className="font-medium truncate min-w-0 flex-1 text-sm">{team.name}</p>
<p className="text-xs shrink-0">
{team.ownerId
? isOwned
? <span className="text-primary font-medium">You</span>
: <span className="text-muted-foreground">{ownerName || "Unknown"}</span>
: <span className="text-muted-foreground/50"></span>
}
</p>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import { Link } from "react-router";
const FOOTER_LINKS = [
{ label: "Home", to: "/" },
{ label: "Rules", to: "/rules" },
{ label: "How to Play", to: "/how-to-play" },
{ label: "Support", to: "/support" },
{ label: "Privacy Policy", to: "/privacy-policy" },
];
export function Footer() {
return (
<footer
className="flex flex-col items-start gap-3 border-t px-10 py-5 text-xs sm:flex-row sm:items-center sm:justify-between"
style={{ color: "rgb(255 255 255 / 28%)" }}
>
<span>© 2026 Brackt. All rights reserved.</span>
<nav className="flex gap-5">
{FOOTER_LINKS.map(({ label, to }) => (
<Link
key={label}
to={to}
className="transition-colors hover:text-muted-foreground"
style={{ color: "inherit" }}
>
{label}
</Link>
))}
</nav>
</footer>
);
}

View file

@ -0,0 +1,446 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { Users, Shuffle, Trophy, ChevronDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
// ─── 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--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const TOTAL_TICKS = 12;
// WCAG 2.3.1: no more than 3 flashes/second → minimum interval 334 ms.
// Curve runs 800 ms → 340 ms over 12 ticks (ramps to full speed at tick 8).
function getInterval(tick: number): number {
const pct = Math.min((tick / TOTAL_TICKS) * 1.5, 1);
return Math.round(800 - (800 - 340) * (pct * pct));
}
function SlotMachineHeadline() {
const [text, setText] = useState(SPORTS[0]);
const [landed, setLanded] = useState(false);
const [animKey, setAnimKey] = useState(0);
const tickRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sportsSeq = useRef<string[]>([]);
useEffect(() => {
sportsSeq.current = shuffle(SPORTS);
let idx = 0;
function tick() {
tickRef.current++;
const sport = sportsSeq.current[idx % sportsSeq.current.length];
idx++;
setText(sport);
setAnimKey((k) => k + 1);
if (tickRef.current >= TOTAL_TICKS) {
setLanded(true);
return;
}
timerRef.current = setTimeout(tick, getInterval(tickRef.current));
}
timerRef.current = setTimeout(tick, 800);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
if (landed) {
return (
<span
className="animate-sport-land block bg-clip-text text-transparent pb-[0.12em]"
style={{ backgroundImage: "linear-gradient(to bottom, #adf661, #2ce1c1)" }}
>
Every Sport.
</span>
);
}
return (
<span key={animKey} className="animate-spin-in block text-white">
{text}
</span>
);
}
// ─── 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() {
const [visible, setVisible] = useState(true);
useEffect(() => {
const onScroll = () => { if (window.scrollY > 50) setVisible(false); };
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div
className="pointer-events-none absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce transition-opacity duration-500"
style={{ opacity: visible ? 0.35 : 0 }}
>
<ChevronDown className="h-6 w-6 text-white" />
</div>
);
}
// ─── Sport Ticker ─────────────────────────────────────────────────────────────
const TICKER_ITEMS = [
...SPORTS.map((s) => ({ sport: s.toUpperCase(), key: `${s}-a` })),
...SPORTS.map((s) => ({ sport: s.toUpperCase(), key: `${s}-b` })),
];
function SportTicker() {
return (
<div className="relative overflow-hidden pb-px" style={{ marginTop: "52px" }}>
<div
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-[60px]"
style={{ background: "linear-gradient(to right, #14171e, transparent)" }}
/>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-[60px]"
style={{ background: "linear-gradient(to left, #14171e, transparent)" }}
/>
<div className="flex animate-ticker" style={{ width: "max-content" }}>
{TICKER_ITEMS.map(({ sport, key }) => (
<span
key={key}
className="mx-1.5 shrink-0 rounded-[3px] px-3 py-1 text-[11px] font-bold uppercase tracking-[0.05em] border bg-card text-muted-foreground"
>
{sport}
</span>
))}
</div>
</div>
);
}
// ─── 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() {
// Interleave step cards with gradient triangle separators
const howItWorksItems: React.ReactNode[] = [];
HOW_IT_WORKS_STEPS.forEach((step, i) => {
howItWorksItems.push(
<Card key={step.title} className="flex-1 gap-2">
<SectionCardHeader icon={step.icon} title={step.title} />
<CardContent className="px-3 sm:px-6">
<p className="text-sm text-muted-foreground">{step.body}</p>
</CardContent>
</Card>
);
if (i < HOW_IT_WORKS_STEPS.length - 1) {
// Triangles reference #brackt-primary-gradient defined in <BracktGradients /> (mounted in root.tsx)
howItWorksItems.push(
<div key={`arrow-${step.title}`} className="flex shrink-0 items-center justify-center py-1 md:px-1 md:py-0">
<svg width="20" height="20" viewBox="0 0 20 20" className="rotate-90 md:rotate-0">
<polygon
points="3,2 18,10 3,18"
fill="url(#brackt-primary-gradient)"
stroke="url(#brackt-primary-gradient)"
strokeWidth={3}
strokeLinejoin="round"
/>
</svg>
</div>
);
}
});
return (
<div className="animate-page-fade-in">
{/* ── Hero ── */}
<section
className="relative flex flex-col items-center justify-center overflow-hidden px-8"
style={{ minHeight: "calc(100vh - var(--navbar-height))" }}
>
{/* Dot-grid texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.07) 1px, transparent 1px)",
backgroundSize: "32px 32px",
maskImage: "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)",
WebkitMaskImage: "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)",
}}
/>
{/* Bracket decorations */}
<div className="lg:hidden">
<BracketDecor mobileTall />
<BracketDecor flip mobileTall />
</div>
<div className="hidden lg:block">
<BracketDecor />
<BracketDecor flip />
</div>
<ScrollHint />
{/* Center content */}
<div className="relative z-10 w-[80%] lg:w-full max-w-[680px] text-center px-6 py-4 lg:px-0 lg:py-0">
<h1
className="mb-0 leading-[0.95] tracking-[-0.04em]"
style={{ fontSize: "clamp(38px, 8vw, 88px)", fontWeight: 900 }}
>
<span className="block text-white">Fantasy</span>
<SlotMachineHeadline />
</h1>
<p
className="mx-auto mt-8 text-muted-foreground"
style={{ fontSize: "16px", fontWeight: 400, lineHeight: 1.65, maxWidth: "414px" }}
>
Draft teams across every league and sport, like the NFL, NHL, EPL, F1, and more.
Compete against your friends in a season-long league.
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-2.5 sm:flex-row">
<Button asChild size="lg" className="text-sm font-extrabold">
<Link to="/leagues/new">Create a League</Link>
</Button>
<Button asChild variant="outline" size="lg" className="text-sm font-semibold">
<Link to="/how-to-play">How it works</Link>
</Button>
</div>
<SportTicker />
</div>
</section>
{/* ── How It Works ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 py-18">
<div className="flex flex-col gap-4 md:flex-row md:items-stretch">
{howItWorksItems}
</div>
</section>
{/* ── Features Grid ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 pb-18">
<div className="overflow-hidden rounded-[6px] border">
<div className="grid grid-cols-1 md:grid-cols-2" style={{ gap: "1px", background: "rgb(255 255 255 / 10%)" }}>
{FEATURES.map(({ title, body }) => (
<div key={title} className="bg-card p-7 transition-colors hover:bg-[#22252e]">
<h3 className="mb-2 text-base font-extrabold tracking-[-0.01em]">{title}</h3>
<p className="text-sm leading-[1.65] text-muted-foreground">{body}</p>
</div>
))}
</div>
</div>
</section>
{/* ── Footer CTA ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 pb-20">
<div className="flex flex-col items-center gap-6 p-12 text-center">
<h2
className="font-black tracking-[-0.025em]"
style={{ fontSize: "clamp(22px, 3vw, 34px)" }}
>
Stop managing your league and start watching it.
</h2>
<p className="text-sm leading-[1.65] text-muted-foreground" style={{ maxWidth: "600px" }}>
Brackt is built for the sports fan who wants a stake in everything without the daily chore of a traditional fantasy roster. Setting up a league takes less than five minutes. Choose your sports, invite your friends, and let the draft decide the winner.
</p>
<Button asChild size="lg" className="text-sm font-extrabold">
<Link to="/leagues/new">Create a League</Link>
</Button>
</div>
</section>
</div>
);
}

View file

@ -1,12 +1,5 @@
import { useState } from "react";
import { Link } from "react-router";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "~/components/ui/navigation-menu";
import {
Sheet,
SheetContent,
@ -14,8 +7,9 @@ import {
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router";
import { MenuIcon } from "lucide-react";
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
interface NavbarProps {
isAdmin: boolean;
@ -27,51 +21,38 @@ export function Navbar({ isAdmin }: NavbarProps) {
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-16 items-center justify-between px-4 md:px-6 lg:px-8">
{/* Logo */}
<Link to="/" className="flex items-center">
<img src="/logo.svg" alt="Brackt" className="h-6" />
</Link>
{/* Left: Logo + Nav links */}
<div className="flex items-center gap-2">
<Link to="/" className="flex items-center mr-2">
<img src="/logo.svg" 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>
</nav>
</div>
{/* Desktop Navigation */}
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/how-to-play">How To Play</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/rules">Rules</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/support">Support</Link>
</NavigationMenuLink>
</NavigationMenuItem>
{isAdmin && (
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/admin">Admin</Link>
</NavigationMenuLink>
</NavigationMenuItem>
)}
</NavigationMenuList>
</NavigationMenu>
{/* Desktop Auth */}
<div className="hidden md:flex items-center gap-4">
{/* Desktop Right: Support + Admin icons + Auth */}
<div className="hidden md:flex items-center gap-2">
<Link
to="/support"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
aria-label="Support"
>
<HelpCircle className="h-5 w-5" />
</Link>
{isAdmin && (
<Link
to="/admin"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
aria-label="Admin"
>
<Settings className="h-5 w-5" />
</Link>
)}
<SignedOut>
<SignInButton mode="modal">
<button className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
Sign In
</button>
<Button>Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
@ -79,19 +60,33 @@ export function Navbar({ isAdmin }: NavbarProps) {
</SignedIn>
</div>
{/* Mobile Menu */}
<div className="flex md:hidden items-center gap-4">
{/* Mobile: icons + Auth + Hamburger */}
<div className="flex md:hidden items-center gap-1">
<Link
to="/support"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Support"
>
<HelpCircle className="h-5 w-5" />
</Link>
{isAdmin && (
<Link
to="/admin"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Admin"
>
<Settings className="h-5 w-5" />
</Link>
)}
<SignedOut>
<SignInButton mode="modal">
<button className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
Sign In
</button>
<Button>Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
@ -105,14 +100,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-4 mt-8">
<Link
to="/"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Home
</Link>
<nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8">
<Link
to="/how-to-play"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
@ -127,22 +115,6 @@ export function Navbar({ isAdmin }: NavbarProps) {
>
Rules
</Link>
<Link
to="/support"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Support
</Link>
{isAdmin && (
<Link
to="/admin"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Admin
</Link>
)}
</nav>
</SheetContent>
</Sheet>

View file

@ -0,0 +1,666 @@
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
export interface BracketMatch {
id: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isComplete: boolean;
participant1Score: string | null;
participant2Score: string | null;
isScoring?: boolean;
participant1?: { id: string; name: string } | null;
participant2?: { id: string; name: string } | null;
winner?: { id: string; name: string } | null;
loser?: { id: string; name: string } | null;
}
export interface BracketOwnership {
participantId: string;
teamName: string;
teamId: string;
ownerName?: string;
}
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
// 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;
const n = parseFloat(score);
if (isNaN(n)) return null;
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
// ─── Match Slot ──────────────────────────────────────────────────────────────
interface ParticipantRowProps {
name: string | null;
isTbd: boolean;
isWinner: boolean;
isLoser: boolean;
isOwned: boolean;
ownership: BracketOwnership | null;
score: string | null;
rowHeight: number;
showScore: boolean;
showOwner: boolean;
showText: boolean;
}
function ParticipantRow({
name,
isTbd,
isWinner,
isLoser,
isOwned,
ownership,
score,
rowHeight,
showScore,
showOwner,
showText,
}: ParticipantRowProps) {
const formattedScore = formatScore(score);
return (
<div
className="relative flex items-center overflow-hidden"
style={{ height: rowHeight }}
>
{showText && (
<div className={[
"flex items-center flex-1 min-w-0 gap-1.5 px-2 pl-[7px] ml-2",
isWinner && isOwned ? "border border-electric/50 bg-electric/8 rounded-md mr-2 my-0.5 self-stretch py-1" :
isWinner ? "border border-white/15 bg-white/5 rounded-md mr-2 my-0.5 self-stretch py-1" : "",
].filter(Boolean).join(" ")}>
{/* Manager avatar — always present for alignment; empty box when unowned */}
<div
className="shrink-0 rounded-[3px] flex items-center justify-center text-[8px] font-bold"
style={{
width: 18,
height: 18,
background: ownership ? "#000" : "transparent",
color: ownership ? avatarColor(ownership.teamName) : undefined,
}}
>
{ownership &&
ownership.teamName
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("")}
</div>
<div className="flex-1 min-w-0">
<span
className={[
"text-[13px] leading-tight block truncate",
isTbd ? "text-muted-foreground/50 italic" : "",
isLoser && isOwned ? "text-electric/50 line-through" :
isLoser ? "text-muted-foreground line-through" : "",
isWinner ? "font-semibold" : "",
]
.filter(Boolean)
.join(" ")}
>
{name ?? "TBD"}
</span>
{/* Owner name below participant name */}
{showOwner && !isTbd && ownership && (
<span className="text-[10px] text-muted-foreground truncate block leading-none">
{ownership.ownerName ?? ownership.teamName}
</span>
)}
</div>
{/* Score */}
{showScore && formattedScore && (
<span
className={[
"shrink-0 text-xs tabular-nums font-medium ml-auto pl-1",
isLoser ? "text-muted-foreground/60" : "",
isWinner ? "text-yellow-400" : "",
]
.filter(Boolean)
.join(" ")}
>
{formattedScore}
</span>
)}
</div>
)}
</div>
);
}
interface BracketMatchSlotProps {
match: BracketMatch;
slotHeight: number;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
}
export function BracketMatchSlot({
match,
slotHeight,
ownershipMap,
userParticipantIds,
}: BracketMatchSlotProps) {
const rowHeight = slotHeight / 2;
const showText = rowHeight >= 10;
const showScore = rowHeight >= 20 && (!!match.participant1Score || match.isComplete);
const showOwner = rowHeight >= 36;
const p1Id = match.participant1Id;
const p2Id = match.participant2Id;
const p1IsWinner = match.isComplete && match.winnerId === p1Id;
const p2IsWinner = match.isComplete && match.winnerId === p2Id;
const p1IsLoser = match.isComplete && match.loserId === p1Id;
const p2IsLoser = match.isComplete && match.loserId === p2Id;
const p1IsOwned = !!(p1Id && userParticipantIds.has(p1Id));
const p2IsOwned = !!(p2Id && userParticipantIds.has(p2Id));
const matchHasOwned = p1IsOwned || p2IsOwned;
const isTbd1 = !p1Id;
const isTbd2 = !p2Id;
const p1Ownership = p1Id ? ownershipMap.get(p1Id) ?? null : null;
const p2Ownership = p2Id ? ownershipMap.get(p2Id) ?? null : null;
// 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)" }
: matchHasOwned
? { background: "rgba(44, 225, 193, 0.4)" }
: { background: "rgba(255, 255, 255, 0.07)" };
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
return (
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
<div className="absolute left-2 right-0 rounded-lg" style={{ ...coronaStyle, top: INSET - 2, bottom: INSET - 2 }} />
{/* Inner card, inset on right to expose corona glow strip */}
<div
className="absolute flex flex-col overflow-hidden"
style={{
top: 0,
right: INSET + 2,
bottom: 0,
left: 0,
borderRadius: 8,
background: "var(--color-muted)",
paddingTop: INSET,
paddingBottom: INSET,
}}
>
<ParticipantRow
name={match.participant1?.name ?? null}
isTbd={isTbd1}
isWinner={p1IsWinner}
isLoser={p1IsLoser}
isOwned={p1IsOwned}
ownership={p1Ownership}
score={match.participant1Score}
rowHeight={rowHeight - INSET}
showScore={showScore}
showOwner={showOwner}
showText={showText}
/>
<ParticipantRow
name={match.participant2?.name ?? null}
isTbd={isTbd2}
isWinner={p2IsWinner}
isLoser={p2IsLoser}
isOwned={p2IsOwned}
ownership={p2Ownership}
score={match.participant2Score}
rowHeight={rowHeight - INSET}
showScore={showScore}
showOwner={showOwner}
showText={showText}
/>
</div>
</div>
);
}
// ─── Per-pair connector column ────────────────────────────────────────────────
interface ConnectorColumnProps {
currentMatches: BracketMatch[];
nextMatches: BracketMatch[];
bracketHeight: number;
}
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
const mid = CONNECTOR_WIDTH / 2;
const paths: string[] = [];
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
// Standard single-elimination halving: U-shape connectors
for (let k = 0; k < nextMatches.length; k++) {
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
const midY = k * nextSlotH + nextSlotH / 2;
const botIdx = 2 * k + 1;
if (botIdx < currentMatches.length) {
const botY = botIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
} else {
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
}
}
} else {
// Non-standard (byes, play-ins, etc.): trace winners by participantId
const winnerToIdx = new Map<string, number>();
currentMatches.forEach((m, idx) => {
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
});
nextMatches.forEach((nextMatch, nextIdx) => {
const destY = nextIdx * nextSlotH + nextSlotH / 2;
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
if (!pId) continue;
const srcIdx = winnerToIdx.get(pId);
if (srcIdx === undefined) continue;
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
}
});
}
return (
<div style={{ flex: `0 0 ${CONNECTOR_WIDTH}px`, position: "relative" }}>
<svg
style={{
position: "absolute",
top: LABEL_HEIGHT,
left: 0,
width: CONNECTOR_WIDTH,
height: bracketHeight,
overflow: "visible",
pointerEvents: "none",
}}
>
{paths.map((d) => (
<path key={d} d={d} fill="none" stroke="rgb(255 255 255 / 22%)" strokeWidth={1.5} />
))}
</svg>
</div>
);
}
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
interface TreeColumnsProps {
visibleRounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
bracketHeight: number;
transitionDuration?: number;
}
export function TreeColumns({
visibleRounds,
matchesByRound,
ownershipMap,
userParticipantIds,
bracketHeight,
transitionDuration,
}: TreeColumnsProps) {
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
return (
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
{visibleRounds.map((round, ri) => {
const roundMatches = matchesByRound.get(round) ?? [];
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
const cardTop = (slotHeight - cardHeight) / 2;
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
return (
<div key={round} style={{ display: "contents" }}>
{/* Round column */}
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
<div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
>
{round}
</div>
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
{roundMatches.map((match, matchIdx) => (
<div
key={match.id}
style={{
position: "absolute",
top: matchIdx * slotHeight + cardTop,
left: 0,
right: 0,
height: cardHeight,
transition: tr ? `top ${tr}, height ${tr}` : undefined,
}}
>
<BracketMatchSlot
match={match}
slotHeight={cardHeight}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
))}
</div>
</div>
{/* Connector between this column and the next */}
{nextRound && (
<ConnectorColumn
currentMatches={roundMatches}
nextMatches={nextMatches}
bracketHeight={bracketHeight}
/>
)}
</div>
);
})}
</div>
);
}
// ─── Full desktop tree ────────────────────────────────────────────────────────
interface BracketTreeViewProps {
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
thirdPlaceRound?: string;
}
export function BracketTreeView({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
thirdPlaceRound,
}: BracketTreeViewProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
return (
<div
className="w-full overflow-x-auto"
style={{ minHeight: bracketHeight + LABEL_HEIGHT + 2 }}
>
<div style={{ minWidth }}>
<TreeColumns
visibleRounds={mainRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={bracketHeight}
/>
{thirdPlaceMatch && (
<div style={{ display: "flex", paddingTop: 20 }}>
<div style={{ flex: 1 }} />
<div style={{ minWidth: COLUMN_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>
<div style={{ height: Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT) }}>
<BracketMatchSlot
match={thirdPlaceMatch}
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
}
// ─── Paginated mobile tree ────────────────────────────────────────────────────
interface BracketTreePaginatedProps {
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
/** Index of the first scoring round — default page starts here */
firstScoringRoundIdx?: number;
thirdPlaceRound?: string;
}
interface AnimState {
fromPage: number;
toPage: number;
dir: "left" | "right";
phase: "sliding" | "settling";
}
export function BracketTreePaginated({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
firstScoringRoundIdx,
thirdPlaceRound,
}: BracketTreePaginatedProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const defaultPage = Math.max(
0,
Math.min(
firstScoringRoundIdx !== undefined
? Math.max(0, firstScoringRoundIdx - 1)
: mainRounds.length - 2,
mainRounds.length - 2,
),
);
const [page, setPage] = useState(defaultPage);
const [anim, setAnim] = useState<AnimState | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
// Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition
useEffect(() => {
if (!anim || anim.phase !== "sliding" || !stripRef.current) return;
const el = stripRef.current;
const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0;
const raf = requestAnimationFrame(() => {
el.style.transition = "transform 200ms ease";
el.style.transform = `translateX(${finalX}px)`;
});
return () => cancelAnimationFrame(raf);
}, [anim]);
// Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions
useEffect(() => {
if (!anim || anim.phase !== "settling") return;
const timer = setTimeout(() => setAnim(null), 520);
return () => clearTimeout(timer);
}, [anim]);
const navigate = (newPage: number) => {
if (anim || newPage < 0 || newPage > mainRounds.length - 2) return;
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
};
const handleTransitionEnd = () => {
if (!anim || anim.phase !== "sliding") return;
if (stripRef.current) {
stripRef.current.style.transition = "none";
stripRef.current.style.transform = "translateX(0)";
}
setPage(anim.toPage);
setAnim(prev => prev ? { ...prev, phase: "settling" } : null);
};
const targetPage = anim ? anim.toPage : page;
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
const label = labelRounds[1] ? `${labelRounds[0]}${labelRounds[1]}` : labelRounds[0];
const calcHeight = (p: number) => {
const rs = mainRounds.slice(p, p + 2);
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
};
const pageHeight = calcHeight(page);
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
const visibleRounds = mainRounds.slice(page, page + 2);
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
// Sliding: show from/to slots side-by-side at their respective heights, no transitions
// Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly
// Null: show current page
let leftRounds: string[];
let rightRounds: string[] = [];
let leftHeight: number;
let rightHeight = 0;
let settlingTransition = false;
if (anim?.phase === "sliding") {
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
} else if (anim?.phase === "settling") {
leftRounds = toRounds;
leftHeight = animToHeight;
settlingTransition = true;
} else {
leftRounds = visibleRounds;
leftHeight = pageHeight;
}
// During settling, minHeight transitions from animFromHeight to animToHeight in sync with cards
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
return (
<div>
<div className="flex items-center gap-2 mb-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(page - 1)}
disabled={page === 0 || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Previous rounds"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
{label}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(page + 1)}
disabled={page + 2 >= mainRounds.length || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Next rounds"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
<div
ref={anim?.phase === "sliding" ? stripRef : undefined}
style={{
display: "flex",
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
}}
onTransitionEnd={handleTransitionEnd}
>
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={leftRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={leftHeight}
transitionDuration={settlingTransition ? 500 : undefined}
/>
</div>
{anim?.phase === "sliding" && (
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={rightRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={rightHeight}
/>
</div>
)}
</div>
</div>
{thirdPlaceMatch && (
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
<div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
>
3rd Place
</div>
<BracketMatchSlot
match={thirdPlaceMatch}
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,116 @@
import type { ConferenceGroup } from "~/lib/bracket-templates";
import {
TreeColumns,
BracketTreePaginated,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
interface NbaBracketLayoutProps {
matches: Array<{ round: string; matchNumber: number }>;
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
conferenceGroups: ConferenceGroup[];
scoringRoundIdx: number;
}
const DESIRED_CARD_HEIGHT = 112;
const CARD_GAP = 14;
function splitMatchesByConference(
matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup
): Map<string, BracketMatch[]> {
const result = new Map<string, BracketMatch[]>();
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
const roundMatches = matchesByRound.get(round) ?? [];
const filtered = roundMatches.filter((m) => allowed.includes(m.matchNumber));
if (filtered.length > 0) result.set(round, filtered);
}
return result;
}
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
}
export function NbaBracketLayout({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
conferenceGroups,
scoringRoundIdx,
}: NbaBracketLayoutProps) {
// Rounds that belong to any conference group
const conferenceRoundSet = new Set(
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
);
// Rounds not in any group are shared (e.g. NBA Finals)
const sharedRounds = rounds.filter((r) => !conferenceRoundSet.has(r));
// Per-conference round lists (preserving template order)
const conferenceRounds = conferenceGroups.map((g) =>
rounds.filter((r) => g.roundMatchNumbers[r] !== undefined)
);
// Shared rounds bracket height
const sharedMatches = new Map(
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
);
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
return (
<>
{/* ── Desktop: two-conference stacked layout ── */}
<div className="hidden md:flex md:flex-col md:gap-8">
{conferenceGroups.map((group, gi) => {
const confRounds = conferenceRounds[gi];
const confMatches = splitMatchesByConference(matchesByRound, group);
const height = bracketHeight(confMatches, confRounds);
return (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<TreeColumns
visibleRounds={confRounds}
matchesByRound={confMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={height}
/>
</div>
);
})}
{sharedRounds.length > 0 && (
<div>
<TreeColumns
visibleRounds={sharedRounds}
matchesByRound={sharedMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={sharedHeight}
/>
</div>
)}
</div>
{/* ── Mobile: paginated across all rounds ── */}
<div className="md:hidden">
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={scoringRoundIdx}
/>
</div>
</>
);
}

View file

@ -1,4 +1,3 @@
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Table,
@ -10,13 +9,24 @@ import {
} from "~/components/ui/table";
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 { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout";
interface Participant {
id: string;
name: string;
}
interface Match {
export interface Match {
id: string;
round: string;
matchNumber: number;
@ -27,13 +37,14 @@ interface Match {
isComplete: boolean;
participant1Score: string | null;
participant2Score: string | null;
isScoring?: boolean;
participant1?: Participant | null;
participant2?: Participant | null;
winner?: Participant | null;
loser?: Participant | null;
}
interface TeamOwnership {
export interface TeamOwnership {
participantId: string;
teamName: string;
teamId: string;
@ -43,14 +54,17 @@ interface TeamOwnership {
interface PlayoffBracketProps {
matches: Match[];
rounds: string[]; // Ordered list of round names (earliest first)
preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage)
participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant
partialScoreParticipantIds?: string[]; // Still-competing participants with provisional floor scores
bracketTemplateId?: string | null;
preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[];
teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[];
showOwnership?: boolean;
title?: string;
description?: string;
/** "bracket" = full bracket + tables (default); "rankings" = final rankings table only */
mode?: "bracket" | "rankings";
}
/** Group matches by round, sorted by matchNumber, in one pass. */
@ -164,9 +178,29 @@ export function computeEliminatedByRound(
return result;
}
/** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) {
if (matchesByRound.get(rounds[i])?.some((m) => m.isScoring)) return i;
}
return 0;
}
const RANKING_ROW_CLASSES: Record<number, string> = {
1: "bg-yellow-500/10",
2: "bg-white/[0.14]",
3: "bg-orange-600/[0.08]",
};
function rankingRowCls(currentRank: number | undefined): string {
const podium = currentRank !== undefined ? RANKING_ROW_CLASSES[currentRank] : undefined;
return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${podium ?? "bg-white/[0.04]"}`;
}
export function PlayoffBracket({
matches,
rounds,
bracketTemplateId = null,
preEliminatedParticipants = [],
participantPoints = [],
partialScoreParticipantIds: _partialScoreParticipantIds = [],
@ -175,27 +209,23 @@ export function PlayoffBracket({
showOwnership = true,
title = "Playoff Bracket",
description,
mode = "bracket",
}: PlayoffBracketProps) {
const userParticipantSet = new Set(userParticipantIds);
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
// Group matches once; reused for feeder map and rendering
const matchesByRound = groupMatchesByRound(matches);
const feederMap = buildFeederMap(matchesByRound, rounds);
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
const getTbdLabel = (round: string, matchNumber: number, slot: "p1" | "p2") => {
const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`);
if (!feeder) return "TBD";
return `Winner of ${feeder.round} M${feeder.matchNumber}`;
};
const thirdPlaceRound = template?.rounds
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
// Build elimination rankings: collect losers per round, then assign rank labels.
// In double-chance brackets (e.g. AFL), a participant may lose one round but
// win a later one — only count them as eliminated at their FINAL losing match.
// Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let _hasScore = false;
let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1];
@ -204,7 +234,6 @@ export function PlayoffBracket({
: null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
// Compute which participants are eliminated in which round, handling double-chance.
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
for (const match of matches) {
@ -215,7 +244,6 @@ export function PlayoffBracket({
match.loserId === match.participant1Id
? match.participant1Score
: match.participant2Score;
if (loserScore) _hasScore = true;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push({
participant: match.loser,
@ -224,17 +252,13 @@ export function PlayoffBracket({
});
}
// Walk rounds latest→earliest to assign rank labels.
// Use TOTAL match count per round (not eliminated-so-far) so that partial scoring
// shows the correct eventual rank. E.g. R64 losers in NCAAM 68 always show T33
// even while other R64 games are still pending.
const allBracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
}
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2; // rank 1 = champion (even if not yet decided)
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
const roundLosers = losersByRound.get(roundName) || [];
@ -248,7 +272,6 @@ export function PlayoffBracket({
nextRank += totalMatchesInRound;
}
// Exclude pre-eliminated participants already ranked via bracket match losers
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
const filteredPreEliminated = preEliminatedParticipants.filter(
@ -257,7 +280,6 @@ export function PlayoffBracket({
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0;
// Build participant lookup from match data for the "In Contention" table
const participantMap = new Map<string, Participant>();
for (const match of matches) {
if (match.participant1) participantMap.set(match.participant1.id, match.participant1);
@ -268,17 +290,16 @@ export function PlayoffBracket({
.map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined);
// Hide participants with 0 points that nobody drafted — they add noise without value.
const isDraftedOrScoring = (participantId: string) =>
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
// Hoist winner row lookups so we don't need an IIFE in JSX
const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false;
const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined;
const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
@ -295,177 +316,54 @@ export function PlayoffBracket({
</div>
) : (
<div className="space-y-8">
{rounds.map((round) => {
const roundMatches = matchesByRound.get(round) || [];
if (roundMatches.length === 0) return null;
return (
<div key={round}>
<div className="flex items-center gap-2 mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{round}
</h3>
<div className="flex-1 border-t border-border" />
<span className="text-xs text-muted-foreground">
{roundMatches.length}{" "}
{roundMatches.length === 1 ? "match" : "matches"}
</span>
</div>
<div className="grid md:grid-cols-2 gap-2">
{roundMatches.map((match) => {
const p1IsWinner =
match.isComplete && match.winnerId === match.participant1Id;
const p2IsWinner =
match.isComplete && match.winnerId === match.participant2Id;
const p1IsLoser =
match.isComplete && match.loserId === match.participant1Id;
const p2IsLoser =
match.isComplete && match.loserId === match.participant2Id;
const p1Name =
match.participant1?.name ||
(match.participant1Id
? "TBD"
: getTbdLabel(round, match.matchNumber, "p1"));
const p2Name =
match.participant2?.name ||
(match.participant2Id
? "TBD"
: getTbdLabel(round, match.matchNumber, "p2"));
const p1IsTbd = !match.participant1Id;
const p2IsTbd = !match.participant2Id;
const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id ?? "");
const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id ?? "");
const matchHasOwned = p1IsOwned || p2IsOwned;
const p1Ownership =
showOwnership && match.participant1Id
? ownershipMap.get(match.participant1Id) || null
: null;
const p2Ownership =
showOwnership && match.participant2Id
? ownershipMap.get(match.participant2Id) || null
: null;
return (
<div
key={match.id}
className={`rounded-lg border bg-card px-3 py-2 ${matchHasOwned ? "border-electric/50" : "border-border"}`}
>
{/* Match label row */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-mono text-muted-foreground">
M{match.matchNumber}
</span>
{match.isComplete && (
<Badge variant="secondary" className="text-xs">
Done
</Badge>
)}
</div>
{/* Participants: left vs right (stacks on mobile) */}
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
{/* Participant 1 — left-aligned */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{p1IsOwned && !p1IsWinner && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
)}
{p1IsWinner && (
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
<span
className={[
"text-sm font-medium truncate",
p1IsLoser ? "text-muted-foreground line-through" : "",
p1IsTbd ? "text-muted-foreground italic font-normal" : "",
p1IsWinner ? "font-semibold" : "",
p1IsOwned && !p1IsLoser ? "text-electric" : "",
]
.filter(Boolean)
.join(" ")}
>
{p1Name}
</span>
</div>
{!p1IsTbd && p1Ownership && (
<div className="mt-1">
<TeamOwnerBadge
teamName={p1Ownership.teamName}
ownerName={p1Ownership.ownerName}
/>
</div>
)}
</div>
{/* Center: scores + vs */}
<div className="shrink-0 flex sm:flex-col items-center gap-1 sm:gap-0.5 min-w-[2.5rem]">
{match.participant1Score ? (
<>
<span className="text-xs tabular-nums font-medium">
{parseFloat(match.participant1Score)}
</span>
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">
vs
</span>
<span className="text-xs tabular-nums font-medium">
{match.participant2Score
? parseFloat(match.participant2Score)
: "—"}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">vs</span>
)}
</div>
{/* Participant 2 — right-aligned on sm+, left-aligned on mobile */}
<div className="flex-1 min-w-0 flex flex-col sm:items-end">
<div className="flex items-center gap-1 sm:justify-end">
<span
className={[
"text-sm font-medium truncate",
p2IsLoser ? "text-muted-foreground line-through" : "",
p2IsTbd ? "text-muted-foreground italic font-normal" : "",
p2IsWinner ? "font-semibold" : "",
p2IsOwned && !p2IsLoser ? "text-electric" : "",
]
.filter(Boolean)
.join(" ")}
>
{p2Name}
</span>
{p2IsWinner && (
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
{p2IsOwned && !p2IsWinner && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
)}
</div>
{!p2IsTbd && p2Ownership && (
<div className="mt-1">
<TeamOwnerBadge
teamName={p2Ownership.teamName}
ownerName={p2Ownership.ownerName}
/>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{mode === "bracket" && (template?.phases ? (
<TabbedBracketLayout
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
phases={template.phases}
scoringRoundIdx={scoringRoundIdx}
/>
) : template?.conferenceGroups ? (
<NbaBracketLayout
matches={matches}
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
conferenceGroups={template.conferenceGroups}
scoringRoundIdx={scoringRoundIdx}
/>
) : (
<>
{/* ── Desktop ── */}
<div className="hidden md:block">
<BracketTreeView
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound}
/>
</div>
);
})}
{/* In Contention */}
{activeParticipants.length > 0 && (
{/* ── Mobile ── */}
<div className="md:hidden">
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound}
/>
</div>
</>
))}
{/* ── In Contention ── */}
{mode === "bracket" && activeParticipants.length > 0 && (
<Card className="border-green-500/30">
<CardHeader>
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
@ -525,134 +423,127 @@ export function PlayoffBracket({
</Card>
)}
{/* Final Rankings / Eliminated Teams */}
{showRankings && (
<Card className="border-electric/30">
<CardHeader>
<CardTitle className="text-electric flex items-center gap-2">
<Trophy className="h-5 w-5" />
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
</CardTitle>
{/* ── Final Rankings ── */}
{mode === "rankings" && showRankings && (
<Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center gap-2">
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
</h2>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Rank</TableHead>
<TableHead>Participant</TableHead>
{showOwnership && (
<TableHead className="w-40 pl-6">Drafted By</TableHead>
)}
{pointsMap.size > 0 && (
<TableHead className="text-right w-16">Pts</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{bracketWinner && (
<TableRow className={winnerIsOwned ? "bg-electric/8 border-l-2 border-l-electric" : "bg-yellow-50/30 dark:bg-yellow-950/10"}>
<TableCell className="font-semibold text-yellow-600 dark:text-yellow-400">
<span className="flex items-center gap-1">
<Trophy className="h-3 w-3" />
#1
</span>
</TableCell>
<TableCell className="font-semibold">
{bracketWinner.name}
{winnerIsOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
<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>
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{winnerOwnership ? (
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
</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 && (
<TableCell className="text-right font-semibold text-electric">
{winnerPts ?? "—"}
</TableCell>
<>
<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>
</>
)}
</TableRow>
)}
</div>
</div>
)}
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
const isOwned = userParticipantSet.has(entry.participant.id);
const pts = pointsMap.get(entry.participant.id);
return (
<TableRow
key={entry.participant.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
>
<TableCell className="font-mono text-xs text-muted-foreground">
{entry.rankLabel}
</TableCell>
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
{entry.participant.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
const isOwned = userParticipantSet.has(entry.participant.id);
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>
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{entry.ownership ? (
<TeamOwnerBadge
teamName={entry.ownership.teamName}
ownerName={entry.ownership.ownerName}
/>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
</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 && (
<TableCell className="text-right tabular-nums text-muted-foreground">
{pts ?? "—"}
</TableCell>
<>
<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>
</>
)}
</TableRow>
);
})}
</div>
</div>
);
})}
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
const isOwned = userParticipantSet.has(p.id);
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null;
const pts = pointsMap.get(p.id);
return (
<TableRow
key={p.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-60" : "opacity-40"}
>
<TableCell className="font-mono text-xs text-muted-foreground"></TableCell>
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
{p.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
const isOwned = userParticipantSet.has(p.id);
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>
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{ownership ? (
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
{pointsMap.size > 0 && (
<TableCell className="text-right tabular-nums text-muted-foreground">
{pts ?? "—"}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</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>
);
})}
</div>
</CardContent>
</Card>
)}
@ -661,3 +552,4 @@ export function PlayoffBracket({
</div>
);
}

View file

@ -92,10 +92,12 @@ interface SportSeasonDisplayProps {
scoringPattern: ScoringPattern;
sportSeasonName: string;
sportName: string;
bracketMode?: "bracket" | "rankings";
// Playoff data
playoffMatches?: Match[];
playoffRounds?: string[];
bracketTemplateId?: string | null;
preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[];
@ -122,8 +124,10 @@ export function SportSeasonDisplay({
scoringPattern,
sportSeasonName,
sportName: _sportName,
bracketMode = "bracket",
playoffMatches = [],
playoffRounds = [],
bracketTemplateId = null,
preEliminatedParticipants = [],
participantPoints = [],
partialScoreParticipantIds = [],
@ -147,6 +151,7 @@ export function SportSeasonDisplay({
<PlayoffBracket
matches={playoffMatches}
rounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds}
@ -154,6 +159,7 @@ export function SportSeasonDisplay({
userParticipantIds={userParticipantIds}
showOwnership={showOwnership}
title="Playoff Bracket"
mode={bracketMode}
/>
);

View file

@ -0,0 +1,246 @@
import { cn } from "~/lib/utils";
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
import {
TreeColumns,
BracketTreePaginated,
BracketMatchSlot,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
interface TabbedBracketLayoutProps {
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
phases: BracketPhase[];
scoringRoundIdx: number;
}
const CARD_H = 112;
const CARD_GAP = 14;
function groupMatches(
matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup
): Map<string, BracketMatch[]> {
const out = new Map<string, BracketMatch[]>();
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
const filtered = (matchesByRound.get(round) ?? []).filter((m) =>
allowed.includes(m.matchNumber)
);
if (filtered.length > 0) out.set(round, filtered);
}
return out;
}
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (CARD_H + CARD_GAP);
}
// ─── Play-In Layout ───────────────────────────────────────────────────────────
interface PlayInColumnProps {
label: string;
description: string;
match: BracketMatch | undefined;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
}
function PlayInColumn({
label,
description,
match,
ownershipMap,
userParticipantIds,
}: PlayInColumnProps) {
return (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center mb-2">
{label}
</p>
{match ? (
<BracketMatchSlot
match={match}
slotHeight={CARD_H}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : (
<div style={{ height: CARD_H }} />
)}
<p className="text-[10px] text-muted-foreground text-center mt-2">{description}</p>
</div>
);
}
interface PlayInLayoutProps {
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
}
function PlayInLayout({ matchesByRound, ownershipMap, userParticipantIds }: PlayInLayoutProps) {
const pir1 = matchesByRound.get("Play-In Round 1") ?? [];
const pir2 = matchesByRound.get("Play-In Round 2") ?? [];
const conferences = [
{
name: "Eastern Conference",
match78: pir1.find((m) => m.matchNumber === 1),
match910: pir1.find((m) => m.matchNumber === 2),
matchFor8: pir2.find((m) => m.matchNumber === 1),
},
{
name: "Western Conference",
match78: pir1.find((m) => m.matchNumber === 3),
match910: pir1.find((m) => m.matchNumber === 4),
matchFor8: pir2.find((m) => m.matchNumber === 2),
},
];
return (
<div className="space-y-6">
{conferences.map((conf) => (
<div key={conf.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
{conf.name}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<PlayInColumn
label="7 VS 8"
description="Winner → 7 seed · Loser plays again"
match={conf.match78}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
<PlayInColumn
label="9 VS 10"
description="Winner plays again · Loser eliminated"
match={conf.match910}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
<PlayInColumn
label="For 8 Seed"
description="Winner → 8 seed · Loser eliminated"
match={conf.matchFor8}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
</div>
))}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function TabbedBracketLayout({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
phases,
scoringRoundIdx,
}: TabbedBracketLayoutProps) {
return (
<div className="space-y-10">
{phases.map((phase) => {
const groupRounds = phase.groups
? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
: [];
const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r));
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
return (
<div key={phase.name}>
<p className={cn(
"text-sm font-semibold uppercase tracking-wider mb-4",
phases.length > 1 ? "text-muted-foreground" : "hidden"
)}>
{phase.name}
</p>
{/* Desktop */}
<div className="hidden md:block">
{phase.layout === "play-in" ? (
<PlayInLayout
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : phase.groups ? (
<div className="space-y-8">
{phase.groups.map((group) => {
const gMatches = groupMatches(matchesByRound, group);
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
return (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<TreeColumns
visibleRounds={gRounds}
matchesByRound={gMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(gMatches, gRounds)}
/>
</div>
);
})}
{sharedRounds.length > 0 && (
<TreeColumns
visibleRounds={sharedRounds}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
/>
)}
</div>
) : (
<TreeColumns
visibleRounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
/>
)}
</div>
{/* Mobile */}
<div className="md:hidden">
{phase.layout === "play-in" ? (
<PlayInLayout
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : (
<BracketTreePaginated
rounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
/>
)}
</div>
</div>
);
})}
</div>
);
}

View file

@ -41,7 +41,6 @@ interface Props {
teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[];
showOtLosses?: boolean;
participantEvs?: Record<string, string>;
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
playoffSpots?: number;
/** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */
@ -223,19 +222,15 @@ function StandingsTable({
teamOwnerships,
userParticipantIds,
showOtLosses,
participantEvs,
hasEvs,
}: {
sections: TableSection[];
teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[];
showOtLosses: boolean;
participantEvs: Record<string, string>;
hasEvs: boolean;
}) {
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr [PROJ]
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr
// OTL and PTS are both shown for hockey (showOtLosses = true)
const totalCols = 10 + (showOtLosses ? 2 : 0) + (hasEvs ? 1 : 0);
const totalCols = 10 + (showOtLosses ? 2 : 0);
return (
<div className="overflow-x-auto -mx-6 px-6">
@ -254,7 +249,6 @@ function StandingsTable({
<th className="text-right py-1.5 px-2 w-12">L10</th>
<th className="text-right py-1.5 px-2 w-12">STK</th>
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
{hasEvs && <th className="text-right py-1.5 pl-2 w-14">PROJ</th>}
</tr>
</thead>
<tbody>
@ -299,7 +293,6 @@ function StandingsTable({
const isUserTeam = userParticipantIds.includes(row.participantId);
const ownership = teamOwnerships[row.participantId];
const ev = participantEvs[row.participantId];
sectionRows.push(
<tr
@ -369,17 +362,6 @@ function StandingsTable({
<span className="text-xs text-muted-foreground"></span>
)}
</td>
{hasEvs && (
<td className="py-2 pl-2 text-right tabular-nums">
{ev !== null ? (
<span className="text-blue-500 dark:text-blue-400 font-medium">
{parseFloat(ev).toFixed(1)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
)}
</tr>
);
});
@ -399,13 +381,11 @@ export function RegularSeasonStandings({
teamOwnerships,
userParticipantIds,
showOtLosses = false,
participantEvs = {},
playoffSpots = 8,
displayMode = "flat",
}: Props) {
if (standings.length === 0) return null;
const hasEvs = Object.keys(participantEvs).length > 0;
const lastSyncedAt = standings
.map((s) => s.syncedAt)
.filter(Boolean)
@ -419,7 +399,7 @@ export function RegularSeasonStandings({
? buildMlbSections(standings)
: buildFlatSections(standings, playoffSpots);
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs };
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses };
return (
<Card>

View file

@ -0,0 +1,136 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EventRow } from "./UpcomingEventsCard";
import type { GroupedEvent } from "./UpcomingEventsCard";
// ─── Shared fixtures ──────────────────────────────────────────────────────────
const tomorrowISO = "2026-04-15T17:00:00.000Z";
const nextWeekStr = "2026-04-22";
const singleLeague = {
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
};
const otherLeague = {
leagueId: "league-xyz-999",
leagueName: "Hardcourt Elites",
};
// ─── EventRow ─────────────────────────────────────────────────────────────────
const eventRowMeta: Meta<typeof EventRow> = {
title: "Sport Season/EventRow",
component: EventRow,
parameters: { layout: "padded" },
};
export default eventRowMeta;
type EventRowStory = StoryObj<typeof EventRow>;
const baseEvent: GroupedEvent = {
id: "event-1",
name: "NBA Playoffs Game 3",
matchLabel: "Lakers vs Celtics",
eventDate: null,
earliestGameTime: tomorrowISO,
eventType: "playoff_game",
sportName: "NBA",
sportSeasonName: "2025 NBA Season",
sportsSeasonPageUrl: "/leagues/league-abc-123/sports-seasons/ss-nba-1",
isAllCompete: false,
totalParticipants: 2,
leagues: [
{
...singleLeague,
participants: [
{ id: "p1", name: "LeBron James" },
{ id: "p2", name: "Jayson Tatum" },
],
},
],
};
export const FirstEvent: EventRowStory = {
args: { event: baseEvent, isFirst: true, isLast: false },
};
export const MiddleEvent: EventRowStory = {
args: {
event: {
...baseEvent,
id: "event-2",
name: "F1 Monaco Grand Prix",
matchLabel: null,
earliestGameTime: null,
eventDate: nextWeekStr,
eventType: "race",
sportName: "F1",
isAllCompete: true,
totalParticipants: 4,
leagues: [
{
...singleLeague,
participants: [
{ id: "p3", name: "Max Verstappen" },
{ id: "p4", name: "Charles Leclerc" },
{ id: "p5", name: "Fernando Alonso" },
{ id: "p6", name: "Lewis Hamilton" },
],
},
],
},
isFirst: false,
isLast: false,
},
};
export const LastEvent: EventRowStory = {
args: {
event: { ...baseEvent, id: "event-last", name: "Snooker World Final", matchLabel: "Round of 16" },
isFirst: false,
isLast: true,
},
};
export const MultiLeague: EventRowStory = {
args: {
event: {
...baseEvent,
id: "event-multi",
name: "Champions League Quarter-Final",
matchLabel: "Man City vs Real Madrid",
sportName: "UCL",
leagues: [
{
...singleLeague,
participants: [{ id: "p10", name: "Erling Haaland" }],
},
{
...otherLeague,
participants: [{ id: "p11", name: "Vinicius Jr" }],
},
],
},
isFirst: true,
isLast: true,
},
};
export const NoLink: EventRowStory = {
args: {
event: { ...baseEvent, sportsSeasonPageUrl: undefined },
isFirst: true,
isLast: true,
},
};
export const TBDDate: EventRowStory = {
args: {
event: { ...baseEvent, earliestGameTime: null, eventDate: null },
isFirst: false,
isLast: true,
},
};

View file

@ -0,0 +1,291 @@
import { format } from "date-fns";
import { ChevronRight, Clock } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router";
import { Badge } from "~/components/ui/badge";
import { LeagueAvatar } from "~/components/league/LeagueAvatar";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { formatEventDate } from "~/lib/date-utils";
import { BRACKT_GRADIENT } from "~/lib/brand";
import type { CalendarPanelEvent } from "./UpcomingCalendarPanel";
interface Props {
events: CalendarPanelEvent[];
title?: string;
limit?: number;
viewAllUrl?: string;
emptyMessage?: string;
/** Whether to show the league avatar next to participants. Default true. */
showLeagueAvatar?: boolean;
}
export interface LeagueParticipants {
leagueId: string;
leagueName: string;
participants: Array<{ id: string; name: string }>;
}
export interface GroupedEvent {
id: string;
name: string;
matchLabel?: string | null;
eventDate: string | null;
earliestGameTime: string | null;
eventType: string;
sportName: string;
sportSeasonName: string;
sportsSeasonPageUrl?: string;
leagues: LeagueParticipants[];
totalParticipants: number;
isAllCompete: boolean;
}
function groupEvents(events: CalendarPanelEvent[]): GroupedEvent[] {
const map = new Map<string, GroupedEvent>();
for (const event of events) {
const existing = map.get(event.id);
const isAllCompete = event.eventType !== "playoff_game";
const leagueEntry: LeagueParticipants = {
leagueId: event.leagueId ?? "unknown",
leagueName: event.leagueName ?? "League",
participants: event.relevantParticipants,
};
if (existing) {
existing.leagues.push(leagueEntry);
existing.totalParticipants += event.relevantParticipants.length;
} else {
map.set(event.id, {
id: event.id,
name: event.name,
matchLabel: event.matchLabel,
eventDate: event.eventDate,
earliestGameTime: event.earliestGameTime,
eventType: event.eventType,
sportName: event.sportName,
sportSeasonName: event.sportSeasonName,
sportsSeasonPageUrl: event.sportsSeasonPageUrl,
leagues: [leagueEntry],
totalParticipants: event.relevantParticipants.length,
isAllCompete,
});
}
}
return Array.from(map.values());
}
function TimelineDot({ isFirst }: { isFirst: boolean }) {
if (isFirst) {
return (
<div
className="relative z-10 mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: BRACKT_GRADIENT }}
/>
);
}
return (
<div className="relative z-10 mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2 border-border bg-background" />
);
}
function ParticipantsByLeague({
leagues,
isAllCompete,
showLeagueAvatar = true,
}: {
leagues: LeagueParticipants[];
isAllCompete: boolean;
showLeagueAvatar?: boolean;
}) {
return (
<div className="space-y-1">
{leagues.map((league) => {
const content =
isAllCompete && league.participants.length > 1 ? (
<span className="text-xs text-muted-foreground">
{league.participants.length} of your picks
</span>
) : (
<span className="flex flex-wrap gap-1">
{league.participants.slice(0, 3).map((p) => (
<Badge key={p.id} variant="secondary" className="text-xs font-normal">
{p.name}
</Badge>
))}
{league.participants.length > 3 && (
<Badge variant="outline" className="text-xs text-muted-foreground">
+{league.participants.length - 3} more
</Badge>
)}
</span>
);
if (!showLeagueAvatar) {
return <div key={league.leagueId}>{content}</div>;
}
return (
<div key={league.leagueId} className="flex items-center gap-1.5">
<LeagueAvatar
leagueId={league.leagueId}
leagueName={league.leagueName}
className="h-5 w-5"
textClassName="text-[8px]"
/>
{content}
</div>
);
})}
</div>
);
}
export function EventRow({
event,
isFirst,
isLast,
showLeagueAvatar = true,
}: {
event: GroupedEvent;
isFirst: boolean;
isLast: boolean;
showLeagueAvatar?: boolean;
}) {
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
const dateStr = gameDate ? format(gameDate, "MMM d") : formatEventDate(event.eventDate);
const timeStr = gameDate
? gameDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
: null;
const displayName = event.matchLabel ? `${event.name}${event.matchLabel}` : event.name;
const content = (
<div className="flex gap-3 min-w-0">
{/* Timeline spine */}
<div className="flex flex-col items-center">
<TimelineDot isFirst={isFirst} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* Content */}
<div className={`flex flex-1 min-w-0 flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 ${isLast ? "" : "pb-10"}`}>
{/* Left: date + name */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
<span
className={`text-xs font-semibold uppercase tracking-wide ${isFirst ? "text-electric" : "text-muted-foreground"}`}
>
{dateStr ?? "TBD"}
</span>
{timeStr && (
<>
<span className="text-border">|</span>
<span className="text-xs text-muted-foreground">{timeStr}</span>
</>
)}
</div>
<div
className={`text-sm font-bold uppercase tracking-wide leading-tight ${isFirst ? "text-foreground" : "text-muted-foreground"}`}
>
{displayName}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{event.sportName}
</div>
</div>
{/* Divider — hidden on mobile where layout is stacked */}
<div className="hidden sm:block w-px self-stretch bg-border shrink-0" />
{/* Right: participants per league */}
<div className="sm:shrink-0 min-w-0">
<ParticipantsByLeague
leagues={event.leagues}
isAllCompete={event.isAllCompete}
showLeagueAvatar={showLeagueAvatar}
/>
</div>
</div>
</div>
);
if (event.sportsSeasonPageUrl) {
return (
<Link to={event.sportsSeasonPageUrl} className="block hover:opacity-80 transition-opacity">
{content}
</Link>
);
}
return <div>{content}</div>;
}
export function UpcomingEventsCard({
events,
title = "My Upcoming Events",
limit,
viewAllUrl,
emptyMessage,
showLeagueAvatar = true,
}: Props) {
const localFilteredEvents = useMemo(() => {
const todayStr = new Intl.DateTimeFormat("en-CA").format(new Date());
return events.filter(
(e) => (e.earliestGameTime ?? e.eventDate ?? "9999-12-31") >= todayStr
);
}, [events]);
const grouped = groupEvents(localFilteredEvents);
const displayedEvents = limit !== undefined ? grouped.slice(0, limit) : grouped;
const hiddenCount = limit !== undefined ? Math.max(0, grouped.length - limit) : 0;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<GradientIcon icon={Clock} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">{title}</span>
</div>
{viewAllUrl && (
<Link
to={viewAllUrl}
className="flex items-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View All
<ChevronRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
{/* Timeline */}
{grouped.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{emptyMessage ?? "No upcoming events in the next 30 days."}
</p>
) : (
<div>
{displayedEvents.map((event, index) => (
<EventRow
key={event.id}
event={event}
isFirst={index === 0}
isLast={index === displayedEvents.length - 1}
showLeagueAvatar={showLeagueAvatar}
/>
))}
{viewAllUrl && hiddenCount > 0 && (
<Link
to={viewAllUrl}
className="flex items-center gap-1 text-sm text-electric hover:underline mt-1"
>
View all {grouped.length} upcoming events
<ChevronRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,121 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { UpcomingEventsCard } from "./UpcomingEventsCard";
import type { CalendarPanelEvent } from "./UpcomingCalendarPanel";
const meta: Meta<typeof UpcomingEventsCard> = {
title: "Sport Season/UpcomingEventsCard",
component: UpcomingEventsCard,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof UpcomingEventsCard>;
const tomorrowISO = "2026-04-15T17:00:00.000Z";
const nextWeekStr = "2026-04-22";
const inTwoWeeksStr = "2026-04-29";
const sampleEvents: CalendarPanelEvent[] = [
{
id: "event-1",
name: "NBA Playoffs Game 3",
matchLabel: "Lakers vs Celtics",
eventDate: null,
earliestGameTime: tomorrowISO,
eventType: "playoff_game",
sportsSeasonId: "ss-nba-1",
relevantParticipants: [
{ id: "p1", name: "LeBron James" },
{ id: "p2", name: "Jayson Tatum" },
],
sportName: "NBA",
sportSeasonName: "2025 NBA Season",
leagueName: "Alpha Dynasty",
leagueId: "league-abc-123",
sportsSeasonPageUrl: "/leagues/league-abc-123/sports-seasons/ss-nba-1",
},
{
id: "event-2",
name: "F1 Monaco Grand Prix",
matchLabel: null,
eventDate: nextWeekStr,
earliestGameTime: null,
eventType: "race",
sportsSeasonId: "ss-f1-1",
relevantParticipants: [
{ id: "p3", name: "Max Verstappen" },
{ id: "p4", name: "Charles Leclerc" },
{ id: "p5", name: "Fernando Alonso" },
],
sportName: "F1",
sportSeasonName: "2025 F1 Season",
leagueName: "Alpha Dynasty",
leagueId: "league-abc-123",
sportsSeasonPageUrl: "/leagues/league-abc-123/sports-seasons/ss-f1-1",
},
{
id: "event-3",
name: "Champions League Quarter-Final",
matchLabel: "Man City vs Real Madrid",
eventDate: inTwoWeeksStr,
earliestGameTime: null,
eventType: "playoff_game",
sportsSeasonId: "ss-ucl-1",
relevantParticipants: [
{ id: "p6", name: "Erling Haaland" },
],
sportName: "UCL",
sportSeasonName: "2024-25 UCL",
leagueName: "Hardcourt Elites",
leagueId: "league-xyz-999",
sportsSeasonPageUrl: "/leagues/league-xyz-999/sports-seasons/ss-ucl-1",
},
];
// Same event appearing in two leagues
const multiLeagueEvents: CalendarPanelEvent[] = [
...sampleEvents,
{
...sampleEvents[0],
leagueName: "Hardcourt Elites",
leagueId: "league-xyz-999",
relevantParticipants: [{ id: "p10", name: "Anthony Davis" }],
},
];
export const WithEvents: Story = {
args: {
events: sampleEvents,
limit: 8,
viewAllUrl: "/upcoming-events",
},
};
export const MultiLeagueGrouped: Story = {
args: {
events: multiLeagueEvents,
limit: 8,
viewAllUrl: "/upcoming-events",
},
};
export const WithLimit: Story = {
args: {
events: sampleEvents,
limit: 1,
viewAllUrl: "/upcoming-events",
},
};
export const Empty: Story = {
args: {
events: [],
viewAllUrl: "/upcoming-events",
},
};
export const NoViewAll: Story = {
args: {
events: sampleEvents,
},
};

View file

@ -0,0 +1,152 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PointProgressionChart } from "./PointProgressionChart";
import type { ChartDataPoint } from "./PointProgressionChart";
const meta: Meta<typeof PointProgressionChart> = {
title: "Standings/PointProgressionChart",
component: PointProgressionChart,
parameters: {
layout: "padded",
docs: {
description: {
component: "Multi-line chart showing team point progression over time. Features monotone curve rendering, interactive legend hover highlighting to isolate individual team lines, and legend sorted by current ranking.",
},
},
},
};
export default meta;
type Story = StoryObj<typeof PointProgressionChart>;
export const Empty: Story = {
args: {
chartData: [],
teams: [],
},
};
const twoTeams = [
{ id: "team1", name: "Thunderhawks" },
{ id: "team2", name: "Storm Chasers" },
];
const twoTeamData = [
{ date: "2024-01-01", Thunderhawks: 25, "Storm Chasers": 20 },
{ date: "2024-01-02", Thunderhawks: 50, "Storm Chasers": 40 },
{ date: "2024-01-03", Thunderhawks: 75, "Storm Chasers": 60 },
{ date: "2024-01-04", Thunderhawks: 100, "Storm Chasers": 80 },
{ date: "2024-01-05", Thunderhawks: 125, "Storm Chasers": 105 },
];
export const TwoTeams: Story = {
args: {
chartData: twoTeamData,
teams: twoTeams,
},
};
const fiveTeams = [
{ id: "team1", name: "Thunderhawks" },
{ id: "team2", name: "Storm Chasers" },
{ id: "team3", name: "Golden Eagles" },
{ id: "team4", name: "Lightning Bolts" },
{ id: "team5", name: "Iron Wolves" },
];
const fiveTeamData = [
{ date: "2024-01-01", Thunderhawks: 25, "Storm Chasers": 20, "Golden Eagles": 30, "Lightning Bolts": 15, "Iron Wolves": 10 },
{ date: "2024-01-02", Thunderhawks: 50, "Storm Chasers": 45, "Golden Eagles": 40, "Lightning Bolts": 35, "Iron Wolves": 25 },
{ date: "2024-01-03", Thunderhawks: 75, "Storm Chasers": 70, "Golden Eagles": 55, "Lightning Bolts": 65, "Iron Wolves": 50 },
{ date: "2024-01-04", Thunderhawks: 100, "Storm Chasers": 95, "Golden Eagles": 85, "Lightning Bolts": 90, "Iron Wolves": 80 },
{ date: "2024-01-05", Thunderhawks: 125, "Storm Chasers": 115, "Golden Eagles": 105, "Lightning Bolts": 110, "Iron Wolves": 100 },
];
export const FiveTeams: Story = {
args: {
chartData: fiveTeamData,
teams: fiveTeams,
},
};
const overlappingTeams = [
{ id: "team1", name: "Alpha" },
{ id: "team2", name: "Beta" },
{ id: "team3", name: "Gamma" },
];
const overlappingData: ChartDataPoint[] = [
{ date: "2024-01-01", Alpha: 100, Beta: 100, Gamma: 100 },
{ date: "2024-01-02", Alpha: 110, Beta: 110, Gamma: 105 },
{ date: "2024-01-03", Alpha: 120, Beta: 115, Gamma: 120 },
{ date: "2024-01-04", Alpha: 125, Beta: 125, Gamma: 125 },
];
export const OverlappingPoints: Story = {
name: "Overlapping Points (Hover Legend)",
args: {
chartData: overlappingData,
teams: overlappingTeams,
},
parameters: {
docs: {
description: {
story: "Demonstrates interactive highlighting via the legend and legend sorted by current ranking. Hover over a team name in the legend to isolate that team's line and dim the others.",
},
},
},
};
const soloTeam = [{ id: "team1", name: "Solo Team" }];
const soloTeamData = [
{ date: "2024-01-01", "Solo Team": 10 },
{ date: "2024-01-02", "Solo Team": 20 },
{ date: "2024-01-03", "Solo Team": 30 },
{ date: "2024-01-04", "Solo Team": 40 },
{ date: "2024-01-05", "Solo Team": 50 },
];
export const SingleTeam: Story = {
args: {
chartData: soloTeamData,
teams: soloTeam,
},
};
const manyTeams = Array.from({ length: 12 }, (_, i) => ({
id: `team${i}`,
name: `Team ${i + 1}`,
}));
const manyTeamData = [
{ date: "2024-01-01", ...Object.fromEntries(manyTeams.map((t, i) => [t.name, (i + 1) * 10])) },
{ date: "2024-01-02", ...Object.fromEntries(manyTeams.map((t, i) => [t.name, (i + 1) * 10 + 15])) },
{ date: "2024-01-03", ...Object.fromEntries(manyTeams.map((t, i) => [t.name, (i + 1) * 10 + 30])) },
];
export const ManyTeams: Story = {
args: {
chartData: manyTeamData,
teams: manyTeams,
},
};
const lateStartTeams = [
{ id: "team1", name: "Early Birds" },
{ id: "team2", name: "Late Bloomer" },
];
const lateStartData: ChartDataPoint[] = [
{ date: "2024-01-01", "Early Birds": 25, "Late Bloomer": 0 },
{ date: "2024-01-02", "Early Birds": 50, "Late Bloomer": 5 },
{ date: "2024-01-03", "Early Birds": 75, "Late Bloomer": 25 },
{ date: "2024-01-04", "Early Birds": 100, "Late Bloomer": 60 },
{ date: "2024-01-05", "Early Birds": 125, "Late Bloomer": 110 },
];
export const LateStart: Story = {
args: {
chartData: lateStartData,
teams: lateStartTeams,
},
};

View file

@ -1,12 +1,62 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
import { useState } from "react";
interface LegendPayload {
value: string;
color: string;
}
interface CustomLegendProps {
payload: LegendPayload[];
onHover: (dataKey: string | null) => void;
onLeave: () => void;
}
function CustomLegend({ payload, onHover, onLeave }: CustomLegendProps) {
return (
<div className="flex flex-wrap md:flex-col gap-3 px-4" role="group" aria-label="Chart legend">
{payload.map((entry) => (
<div
key={entry.value}
className="flex items-center gap-2 cursor-pointer transition-opacity hover:opacity-80"
style={{ color: entry.color }}
onMouseEnter={() => onHover(entry.value)}
onMouseLeave={onLeave}
role="button"
aria-label={`Toggle ${entry.value} line`}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHover(entry.value);
}
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onLeave();
}
}}
>
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: entry.color }}
aria-hidden="true"
/>
<span className="text-sm font-medium">{entry.value}</span>
</div>
))}
</div>
);
}
interface Team {
id: string;
name: string;
}
interface ChartDataPoint {
export interface ChartDataPoint {
date: string;
[teamName: string]: string | number;
}
@ -36,6 +86,8 @@ function formatDate(dateStr: string) {
}
export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
if (chartData.length === 0) {
return (
<Card>
@ -53,6 +105,13 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
);
}
const latestData = chartData[chartData.length - 1];
const teamsByRank = [...teams].toSorted((a, b) => {
const aPoints = (typeof latestData[a.name] === 'number' ? latestData[a.name] : 0) as number;
const bPoints = (typeof latestData[b.name] === 'number' ? latestData[b.name] : 0) as number;
return bPoints - aPoints;
});
return (
<Card>
<CardHeader>
@ -62,46 +121,45 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
className="text-xs"
tick={{ fill: 'currentColor' }}
/>
<YAxis
label={{ value: 'Points', angle: -90, position: 'insideLeft' }}
className="text-xs"
tick={{ fill: 'currentColor' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
labelFormatter={(label) => formatDate(label as string)}
formatter={(value: number) => [value.toFixed(2), '']}
/>
<Legend
wrapperStyle={{ paddingTop: '20px' }}
/>
{teams.map((team, index) => (
<Line
key={team.id}
type="monotone"
dataKey={team.name}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
<div className="flex flex-col md:flex-row md:items-center gap-6">
<div className="flex-1 min-w-0">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
className="text-xs"
tick={{ fill: 'currentColor' }}
/>
<YAxis
width={40}
className="text-xs"
tick={{ fill: 'currentColor' }}
/>
{teams.map((team, index) => (
<Line
key={team.id}
type="monotone"
dataKey={team.name}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={false}
strokeOpacity={hoveredLine === null || hoveredLine === team.name ? 1 : 0.1}
isAnimationActive={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<CustomLegend
payload={teamsByRank.map((team) => ({ value: team.name, color: COLORS[teams.findIndex(t => t.id === team.id) % COLORS.length] }))}
onHover={setHoveredLine}
onLeave={() => setHoveredLine(null)}
/>
</div>
</CardContent>
</Card>
);

View file

@ -0,0 +1,192 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RecentScoresCard } from "./RecentScoresCard";
import type { ScoreEventEntry } from "./RecentScoresCard";
const meta: Meta<typeof RecentScoresCard> = {
title: "Standings/RecentScoresCard",
component: RecentScoresCard,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof RecentScoresCard>;
const now = new Date();
const baseEvent: ScoreEventEntry = {
id: "event-1",
teamId: "team-1",
teamName: "Thunderhawks",
ownerName: "Chris M.",
scoringEventId: "se-1",
scoringEventName: "NBA Playoffs Game 3",
sportName: "NBA",
pointsDelta: "25.5",
occurredAt: now.toISOString(),
participants: [
{ id: "p1", name: "LeBron James" },
{ id: "p2", name: "Jayson Tatum" },
],
};
export const Empty: Story = {
args: {
events: [],
},
};
export const SingleEvent: Story = {
args: {
events: [baseEvent],
},
};
export const MultipleEvents: Story = {
args: {
events: [
baseEvent,
{
...baseEvent,
id: "event-2",
teamName: "Storm Chasers",
ownerName: "Sarah J.",
scoringEventName: "F1 Monaco Grand Prix",
sportName: "F1",
pointsDelta: "45",
occurredAt: new Date(now.getTime() - 3 * 60 * 60 * 1000).toISOString(),
participants: [
{ id: "p3", name: "Max Verstappen" },
{ id: "p4", name: "Charles Leclerc" },
],
},
{
...baseEvent,
id: "event-3",
teamName: "Golden Eagles",
ownerName: "Mike T.",
scoringEventName: "Champions League Quarter-Final",
sportName: "UCL",
pointsDelta: "30.0",
occurredAt: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(),
participants: [
{ id: "p5", name: "Erling Haaland" },
{ id: "p6", name: "Vinicius Jr" },
],
},
],
},
};
export const RecentAndOld: Story = {
args: {
events: [
{
...baseEvent,
id: "event-recent-1",
pointsDelta: "20",
occurredAt: new Date(now.getTime() - 30 * 60 * 1000).toISOString(),
},
{
...baseEvent,
id: "event-recent-2",
teamName: "Lightning Bolts",
ownerName: "Alex K.",
scoringEventName: "NBA Playoffs Game 4",
sportName: "NBA",
pointsDelta: "15.5",
occurredAt: new Date(now.getTime() - 6 * 60 * 60 * 1000).toISOString(),
participants: [{ id: "p7", name: "Stephen Curry" }],
},
{
...baseEvent,
id: "event-old-1",
teamName: "Cobras",
ownerName: "Jennifer L.",
scoringEventName: "Premier League Matchday 35",
sportName: "Premier League",
pointsDelta: "35",
occurredAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(),
participants: [
{ id: "p8", name: "Mohamed Salah" },
{ id: "p9", name: "Kevin De Bruyne" },
],
},
{
...baseEvent,
id: "event-old-2",
teamName: "Wolves",
ownerName: null,
scoringEventName: "Snooker World Championship",
sportName: "Snooker",
pointsDelta: "12",
occurredAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
participants: [{ id: "p10", name: "Ronnie O'Sullivan" }],
},
],
},
};
export const IntegerPoints: Story = {
args: {
events: [
{ ...baseEvent, pointsDelta: "10", occurredAt: new Date(now.getTime() - 1 * 60 * 60 * 1000).toISOString() },
{ ...baseEvent, id: "event-2", pointsDelta: "25", occurredAt: new Date(now.getTime() - 5 * 60 * 60 * 1000).toISOString() },
{ ...baseEvent, id: "event-3", pointsDelta: "40", occurredAt: new Date(now.getTime() - 10 * 60 * 60 * 1000).toISOString() },
],
},
};
export const DecimalPoints: Story = {
args: {
events: [
{ ...baseEvent, pointsDelta: "12.5", occurredAt: new Date(now.getTime() - 1 * 60 * 60 * 1000).toISOString() },
{ ...baseEvent, id: "event-2", pointsDelta: "8.3", occurredAt: new Date(now.getTime() - 5 * 60 * 60 * 1000).toISOString() },
{ ...baseEvent, id: "event-3", pointsDelta: "33.7", occurredAt: new Date(now.getTime() - 10 * 60 * 60 * 1000).toISOString() },
],
},
};
export const NoOwnerName: Story = {
args: {
events: [
{ ...baseEvent, ownerName: null, occurredAt: new Date(now.getTime() - 1 * 60 * 60 * 1000).toISOString() },
{ ...baseEvent, id: "event-2", teamName: "Iron Wolves", ownerName: null, occurredAt: new Date(now.getTime() - 5 * 60 * 60 * 1000).toISOString() },
],
},
};
export const SingleParticipant: Story = {
args: {
events: [
{ ...baseEvent, participants: [{ id: "p1", name: "LeBron James" }] },
{ ...baseEvent, id: "event-2", participants: [{ id: "p2", name: "Max Verstappen" }] },
],
},
};
export const MultipleParticipants: Story = {
args: {
events: [
{
...baseEvent,
participants: [
{ id: "p1", name: "LeBron James" },
{ id: "p2", name: "Jayson Tatum" },
{ id: "p3", name: "Stephen Curry" },
],
},
{
...baseEvent,
id: "event-2",
participants: [
{ id: "p4", name: "Max Verstappen" },
{ id: "p5", name: "Charles Leclerc" },
{ id: "p6", name: "Fernando Alonso" },
{ id: "p7", name: "Lewis Hamilton" },
],
},
],
},
};

View file

@ -0,0 +1,116 @@
import { formatDistanceToNow } from "date-fns";
import { TrendingUp } from "lucide-react";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { BRACKT_GRADIENT } from "~/lib/brand";
export interface ScoreEventEntry {
id: string;
teamId: string;
teamName: string;
ownerName: string | null;
scoringEventId: string | null;
scoringEventName: string | null;
sportName: string | null;
pointsDelta: string;
occurredAt: Date | string;
participants: Array<{ id: string; name: string }>;
}
interface RecentScoresCardProps {
events: ScoreEventEntry[];
}
function TimelineDot({ isRecent }: { isRecent: boolean }) {
return (
<div
suppressHydrationWarning
className={`relative z-10 mt-1 h-2.5 w-2.5 shrink-0 rounded-full${isRecent ? "" : " border-2 border-border bg-background"}`}
style={isRecent ? { background: BRACKT_GRADIENT } : undefined}
/>
);
}
function ScoreRow({
event,
isLast,
}: {
event: ScoreEventEntry;
isLast: boolean;
}) {
const pts = parseFloat(event.pointsDelta);
const displayPts = Number.isInteger(pts) ? pts.toString() : pts.toFixed(1);
const managerName = event.ownerName ?? event.teamName;
const isRecent = Date.now() - new Date(event.occurredAt).getTime() < 24 * 60 * 60 * 1000;
return (
<div className="flex gap-3 min-w-0">
{/* Timeline spine */}
<div className="flex flex-col items-center">
<TimelineDot isRecent={isRecent} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* Content row: points box + text */}
<div className={`flex gap-3 flex-1 min-w-0 ${isLast ? "" : "pb-6"}`}>
{/* Points box — square, no rounded corners */}
<div className="shrink-0 w-10 h-10 flex items-center justify-center bg-black text-white font-bold text-sm leading-none self-start">
+{displayPts}
</div>
{/* Text content */}
<div className="flex flex-col gap-0.5 min-w-0">
{/* Date */}
<div className="text-xs text-muted-foreground/60" suppressHydrationWarning>
{formatDistanceToNow(new Date(event.occurredAt), { addSuffix: true })}
</div>
{/* Manager name */}
<div className="text-sm font-bold text-foreground leading-tight">
{managerName}
</div>
{/* Sport · participant name(s) */}
<div className="text-xs text-muted-foreground">
{[
event.sportName,
event.participants.length > 0
? event.participants.map((p) => p.name).join(", ")
: null,
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
</div>
</div>
);
}
export function RecentScoresCard({ events }: RecentScoresCardProps) {
return (
<div>
{/* Header */}
<div className="flex items-center gap-2 mb-6">
<GradientIcon icon={TrendingUp} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Recent Scores</span>
</div>
{/* Timeline */}
{events.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No recent scores yet they&apos;ll appear here as events complete.
</p>
) : (
<div>
{events.map((event, index) => (
<ScoreRow
key={event.id}
event={event}
isLast={index === events.length - 1}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,21 @@
/**
* Renders hidden SVG gradient definitions that can be referenced by ID
* anywhere in the document via stroke="url(#brackt-primary-gradient)" etc.
*
* Mount this once near the root of the app.
*/
export function BracktGradients() {
return (
<svg width="0" height="0" aria-hidden className="absolute">
<defs>
{/* Green (top) Cyan (bottom) the main Brackt brand gradient.
userSpaceOnUse + 024 matches the Lucide icon viewBox so horizontal
strokes (zero bounding-box height) don't produce a degenerate gradient. */}
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#adf661" />
<stop offset="100%" stopColor="#2ce1c1" />
</linearGradient>
</defs>
</svg>
);
}

View file

@ -0,0 +1,30 @@
import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react";
interface GradientIconProps extends Omit<LucideProps, "color"> {
icon: ComponentType<LucideProps>;
/** Defaults to the primary brand gradient (green → cyan). */
gradientId?: string;
}
/**
* Renders any Lucide icon with its stroke set to a named SVG gradient.
* Requires <BracktGradients /> to be mounted somewhere in the document.
*
* Usage:
* <GradientIcon icon={Shield} className="h-5 w-5" />
* <GradientIcon icon={Trophy} gradientId="brackt-primary-gradient" className="h-6 w-6" />
*/
export function GradientIcon({
icon: Icon,
gradientId = "brackt-primary-gradient",
style,
...props
}: GradientIconProps) {
return (
<Icon
{...props}
style={{ stroke: `url(#${gradientId})`, ...style }}
/>
);
}

View file

@ -0,0 +1,28 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideProps } from "lucide-react";
import { CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon";
interface SectionCardHeaderProps {
icon: ComponentType<LucideProps>;
title: string;
right?: ReactNode;
}
/**
* Consistent header for top-level section cards (My Leagues, Create a League, etc.).
* Renders a gradient icon + xl bold title, with an optional right-side slot.
*/
export function SectionCardHeader({ icon, title, right }: SectionCardHeaderProps) {
return (
<CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GradientIcon icon={icon} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">{title}</h2>
</div>
{right}
</div>
</CardHeader>
);
}

View file

@ -5,11 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "app/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-xs font-semibold uppercase tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
"bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] text-primary-foreground hover:opacity-90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:

View file

@ -1,20 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "app/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
const cardVariants = cva("", {
variants: {
variant: {
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)]",
},
},
defaultVariants: {
variant: "default",
},
})
function Card({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof cardVariants>) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
className={cn(cardVariants({ variant }), className)}
{...props}
/>
)
}
export { cardVariants }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div

View file

@ -1,9 +1,24 @@
import { getAvatarColor } from "~/lib/color-hash";
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;
}
interface TeamOwnerBadgeProps {
teamName: string;
ownerName?: string;
align?: "left" | "right"; // right = avatar on the right side for the right column
align?: "left" | "right";
}
export function TeamOwnerBadge({
@ -11,14 +26,23 @@ export function TeamOwnerBadge({
ownerName,
align = "left",
}: TeamOwnerBadgeProps) {
const initial = teamName.charAt(0).toUpperCase();
const colorClass = getAvatarColor(teamName);
const initials =
teamName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
const color = AVATAR_COLORS[hashName(teamName) % AVATAR_COLORS.length];
const avatar = (
<div
className={`h-6 w-6 rounded-full ${colorClass} text-white text-xs font-bold flex items-center justify-center shrink-0`}
role="img"
aria-label={teamName}
className="h-6 w-6 flex shrink-0 items-center justify-center font-bold text-[10px]"
style={{ backgroundColor: "#000", color }}
>
{initial}
{initials}
</div>
);

View file

@ -61,6 +61,37 @@ export interface BracketRegion {
playIns: BracketPlayIn[];
}
/**
* Defines a named conference or sub-bracket group within a bracket.
* Used to split matches into East/West (NBA) or regional groups (NCAA) for display.
*/
export interface ConferenceGroup {
/** Display name, e.g. "Eastern Conference" */
name: string;
/**
* Maps round name the match numbers (1-based) that belong to this conference.
* Rounds not listed here are either shared (Finals) or not applicable.
*/
roundMatchNumbers: Record<string, number[]>;
}
/**
* A named tab/section within a bracket display.
* Either shows a simple list of rounds, or a set of conference/regional sub-brackets.
*/
export interface BracketPhase {
/** Tab label */
name: string;
/** Simple ordered list of round names to show in this tab */
rounds?: string[];
/** Regional/conference sub-groups to show stacked in this tab */
groups?: ConferenceGroup[];
/** Rounds rendered after groups (e.g. Final Four, Championship) */
sharedRounds?: string[];
/** Custom rendering layout for special phases */
layout?: "play-in";
}
export interface BracketTemplate {
/** Unique identifier for this template */
id: string;
@ -88,6 +119,17 @@ export interface BracketTemplate {
* Length must equal totalTeams.
*/
participantLabels?: string[];
/**
* Optional conference/group split for brackets with parallel sub-brackets (NBA).
* When present, the UI renders each group as a separate sub-bracket.
* Rounds not listed in any group's roundMatchNumbers are treated as shared (e.g., Finals).
*/
conferenceGroups?: ConferenceGroup[];
/**
* Optional tabbed phase display (NCAA).
* When present, the UI renders a tab switcher with each phase as a section.
*/
phases?: BracketPhase[];
}
/** All seed numbers in a standard 16-team regional bracket (116) */
@ -380,7 +422,7 @@ export const DARTS_128: BracketTemplate = {
* First Four (play-in) Round of 64 Round of 32 Sweet Sixteen Elite Eight Final Four Championship
* Only Elite Eight and beyond score points per Q18
*
* 2026 region config:
* Region config (year-specific update each March when the First Four matchups are announced):
* East 16 direct seeds, no play-ins
* South 15 direct seeds, 16-seed play-in
* West 15 direct seeds, 11-seed play-in
@ -474,6 +516,55 @@ export const NCAA_68: BracketTemplate = {
],
},
],
// Two-tab display: First Four → Bracket (4 regional sub-brackets + Final Four)
phases: [
{
name: "First Four",
rounds: ["First Four"],
},
{
name: "Bracket",
groups: [
{
name: "East Region",
roundMatchNumbers: {
"Round of 64": [1, 2, 3, 4, 5, 6, 7, 8],
"Round of 32": [1, 2, 3, 4],
"Sweet Sixteen": [1, 2],
"Elite Eight": [1],
},
},
{
name: "South Region",
roundMatchNumbers: {
"Round of 64": [9, 10, 11, 12, 13, 14, 15, 16],
"Round of 32": [5, 6, 7, 8],
"Sweet Sixteen": [3, 4],
"Elite Eight": [2],
},
},
{
name: "West Region",
roundMatchNumbers: {
"Round of 64": [17, 18, 19, 20, 21, 22, 23, 24],
"Round of 32": [9, 10, 11, 12],
"Sweet Sixteen": [5, 6],
"Elite Eight": [3],
},
},
{
name: "Midwest Region",
roundMatchNumbers: {
"Round of 64": [25, 26, 27, 28, 29, 30, 31, 32],
"Round of 32": [13, 14, 15, 16],
"Sweet Sixteen": [7, 8],
"Elite Eight": [4],
},
},
],
sharedRounds: ["Final Four", "Championship"],
},
],
};
/**
@ -767,6 +858,17 @@ export const NBA_20: BracketTemplate = {
"West 1", "West 2", "West 3", "West 4", "West 5",
"West 6", "West 7", "West 8", "West 9", "West 10",
],
phases: [
{
name: "Play-In",
rounds: ["Play-In Round 1", "Play-In Round 2"],
layout: "play-in" as const,
},
{
name: "Playoffs",
rounds: ["First Round", "Conference Semifinals", "Conference Finals", "NBA Finals"],
},
],
};
/**

3
app/lib/brand.ts Normal file
View file

@ -0,0 +1,3 @@
export const BRACKT_GRADIENT = "linear-gradient(to bottom, #adf661, #2ce1c1)";
export const BRACKT_GRADIENT_START = "#adf661";
export const BRACKT_GRADIENT_END = "#2ce1c1";

View file

@ -1,6 +1,7 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
export async function createDraftPick(data: {
seasonId: string;
@ -103,6 +104,129 @@ export async function getDraftedParticipantsBySportsSeason(
return map;
}
export interface DraftedParticipantWithPoints {
id: string;
name: string;
/** Fantasy league points earned (position → scoring rules). null = no result yet. */
earnedPoints: number | null;
/** Accumulated qualifying points for qualifying_points sports. null for other sports. */
currentQP: number | null;
}
/**
* Get a team's drafted participants with their current earned points, grouped by
* sports season. Used for the league home summary card.
*
* - playoff_bracket / season_standings: earnedPoints = finalPosition scoring rules
* - qualifying_points (active): currentQP = accumulated QP from participantQualifyingTotals
* - qualifying_points (finalized): earnedPoints = finalPosition scoring rules (participantResults written)
* - No EV / projected points actual earned only.
*/
export async function getDraftedParticipantsWithPoints(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Map<string, DraftedParticipantWithPoints[]>> {
const db = providedDb || database();
const scoringRules = await getScoringRules(seasonId, db);
if (!scoringRules) return new Map();
// One query: picks → participant → sportsSeason (scoringPattern) + results (position)
const picks = await db.query.draftPicks.findMany({
where: and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
),
columns: {},
with: {
participant: {
columns: { id: true, name: true, sportsSeasonId: true },
with: {
sportsSeason: { columns: { id: true, scoringPattern: true } },
results: { columns: { finalPosition: true }, limit: 1 },
},
},
},
});
// Collect unique sportsSeasonIds per scoring pattern
const bracketSeasonIds = new Set<string>();
const qpSeasonIds = new Set<string>();
const qpParticipantIds = new Set<string>();
for (const pick of picks) {
const pattern = pick.participant.sportsSeason.scoringPattern;
const ssId = pick.participant.sportsSeasonId;
if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId);
if (pattern === "qualifying_points") {
qpSeasonIds.add(ssId);
qpParticipantIds.add(pick.participant.id);
}
}
// Batch-fetch bracket template IDs (one per sports season)
const bracketTemplateMap = new Map<string, string | null>();
if (bracketSeasonIds.size > 0) {
const events = await db.query.scoringEvents.findMany({
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
columns: { sportsSeasonId: true, bracketTemplateId: true },
});
for (const ev of events) {
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
}
}
}
// Batch-fetch QP totals for qualifying_points participants
const qpMap = new Map<string, number>(); // participantId → totalQP
if (qpParticipantIds.size > 0 && qpSeasonIds.size > 0) {
const totals = await db.query.participantQualifyingTotals.findMany({
where: and(
inArray(schema.participantQualifyingTotals.participantId, [...qpParticipantIds]),
inArray(schema.participantQualifyingTotals.sportsSeasonId, [...qpSeasonIds])
),
columns: { participantId: true, totalQualifyingPoints: true },
});
for (const row of totals) {
qpMap.set(row.participantId, parseFloat(row.totalQualifyingPoints));
}
}
// Assemble result grouped by sportsSeasonId
const result = new Map<string, DraftedParticipantWithPoints[]>();
for (const pick of picks) {
const { id, name, sportsSeasonId } = pick.participant;
const pattern = pick.participant.sportsSeason.scoringPattern;
const resultRow = pick.participant.results[0] ?? null;
let earnedPoints: number | null = null;
let currentQP: number | null = null;
if (pattern === "qualifying_points" && (resultRow?.finalPosition === null || resultRow?.finalPosition === undefined)) {
// Active QP season: show accumulated qualifying points
currentQP = qpMap.get(id) ?? null;
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
// Finalized result for any pattern (including finalized QP seasons)
earnedPoints = pattern === "playoff_bracket"
? calculateBracketPoints(
resultRow.finalPosition,
scoringRules,
bracketTemplateMap.get(sportsSeasonId) ?? null
)
: calculateFantasyPoints(resultRow.finalPosition, scoringRules);
}
const arr = result.get(sportsSeasonId) ?? [];
result.set(sportsSeasonId, arr);
arr.push({ id, name, earnedPoints, currentQP });
}
return result;
}
export async function deleteAllDraftPicks(seasonId: string) {
const db = database();
await db

View file

@ -9,6 +9,7 @@ import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { doesLoserAdvance } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger";
/**
@ -393,6 +394,7 @@ export async function processMatchResult(
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
}
// Non-scoring round wins are not surfaced in the Recent Scores feed.
} else {
const config = getRoundConfig(round, bracketTemplateId);
@ -402,15 +404,37 @@ export async function processMatchResult(
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
);
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
if (oldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: winnerId, sportsSeasonId, oldFloor, newFloor: 5,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
} else if (config.winnerFloor === null) {
// Finalization round: winner and loser both get their exact positions.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db);
const winnerPosition = config.winnerPosition ?? 1;
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, winnerPosition, db);
if (oldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: winnerId, sportsSeasonId, oldFloor, newFloor: winnerPosition,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
} else {
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
if (oldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: winnerId, sportsSeasonId, oldFloor, newFloor: config.winnerFloor,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
}
}
@ -431,11 +455,15 @@ export async function processMatchResult(
}
/**
* Helper to upsert participant result
* Helper to upsert participant result.
*
* isPartialScore=true means the participant is still alive and this placement
* is their guaranteed minimum floor it will be replaced as they advance or
* when they are finally eliminated.
*
* Returns the previous finalPosition (or 0 if this is a new row), so callers
* can compute exact point deltas. Returns null when the update was skipped
* (i.e. trying to set a provisional floor on an already-finalized participant).
*/
async function upsertParticipantResult(
participantId: string,
@ -443,7 +471,7 @@ async function upsertParticipantResult(
finalPosition: number,
db: ReturnType<typeof database>,
isPartialScore = false
): Promise<void> {
): Promise<number | null> {
const existing = await db.query.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
@ -455,7 +483,7 @@ async function upsertParticipantResult(
// Never un-finalize: if the row is already finalized (isPartialScore=false),
// a call with isPartialScore=true must not overwrite it (e.g. a re-run of
// processPlayoffEvent after a manual finalization).
if (!existing.isPartialScore && isPartialScore) return;
if (!existing.isPartialScore && isPartialScore) return null;
await db
.update(schema.participantResults)
@ -465,6 +493,7 @@ async function upsertParticipantResult(
updatedAt: new Date(),
})
.where(eq(schema.participantResults.id, existing.id));
return existing.finalPosition ?? 0;
} else {
await db.insert(schema.participantResults).values({
participantId,
@ -472,6 +501,7 @@ async function upsertParticipantResult(
finalPosition,
isPartialScore,
});
return 0;
}
}

View file

@ -0,0 +1,258 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray, desc, sql, and } from "drizzle-orm";
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
import { logger } from "~/lib/logger";
/**
* Low-level primitive: writes one team_score_events row with an explicit pointsDelta.
* participantIds are captured at write time so attribution is accurate regardless
* of future match results.
*
* When matchId is provided (bracket sports), one row is written per match using
* a partial unique index on (teamId, seasonId, matchId). When matchId is absent
* (non-bracket fallback), one row per (teamId, seasonId, scoringEventId) is used.
*
* For bracket sports, prefer calling recordMatchScoreEvents instead it computes
* the exact per-season delta from scoring rules rather than requiring the caller
* to supply a pre-computed pointsDelta.
*/
export async function recordTeamScoreEvent(
params: {
teamId: string;
seasonId: string;
scoringEventId: string;
scoringEventName: string | null;
sportName: string | null;
participantIds: string[];
pointsDelta: number;
occurredAt?: Date;
matchId?: string;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const values = {
teamId: params.teamId,
seasonId: params.seasonId,
scoringEventId: params.scoringEventId,
scoringEventName: params.scoringEventName,
sportName: params.sportName,
matchId: params.matchId ?? null,
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
occurredAt: params.occurredAt ?? new Date(),
};
if (params.matchId) {
// Per-match path: unique on (teamId, seasonId, matchId) WHERE matchId IS NOT NULL
await db
.insert(schema.teamScoreEvents)
.values(values)
.onConflictDoUpdate({
target: [
schema.teamScoreEvents.teamId,
schema.teamScoreEvents.seasonId,
schema.teamScoreEvents.matchId,
],
targetWhere: sql`match_id IS NOT NULL`,
set: {
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
},
});
} else {
// Event-level fallback: unique on (teamId, seasonId, scoringEventId) WHERE matchId IS NULL
await db
.insert(schema.teamScoreEvents)
.values(values)
.onConflictDoUpdate({
target: [
schema.teamScoreEvents.teamId,
schema.teamScoreEvents.seasonId,
schema.teamScoreEvents.scoringEventId,
],
targetWhere: sql`match_id IS NULL`,
set: {
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
},
});
}
}
/**
* Records a score event for every fantasy season that uses the given sports season,
* attributing the exact point delta to the specific match winner.
*
* Called from processMatchResult immediately after upsertParticipantResult sets the
* winner's new floor, so the delta is computed from the exact position change rather
* than from aggregate before/after standings totals.
*
* oldFloor is 0 when the participant had no prior result row (first match win).
*/
export async function recordMatchScoreEvents(
params: {
participantId: string;
sportsSeasonId: string;
oldFloor: number;
newFloor: number;
bracketTemplateId: string | null;
matchId: string;
eventId: string;
eventName: string | null;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// Fetch sport name for display (one query, shared across all seasons)
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
with: { sport: { columns: { name: true } } },
});
const sportName = sportsSeason?.sport?.name ?? null;
// All fantasy seasons that include this sports season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
columns: { seasonId: true },
});
if (seasonSports.length === 0) return;
const seasonIds = seasonSports.map((ss) => ss.seasonId);
// Batch fetch: which team in each season drafted this participant?
const picks = await db.query.draftPicks.findMany({
where: and(
inArray(schema.draftPicks.seasonId, seasonIds),
eq(schema.draftPicks.participantId, params.participantId)
),
columns: { teamId: true, seasonId: true },
});
if (picks.length === 0) return;
const teamBySeasonId = new Map(picks.map((p) => [p.seasonId, p.teamId]));
// Batch fetch scoring rules for all seasons in one query
const seasonRows = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
columns: {
id: true,
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
pointsFor7th: true, pointsFor8th: true,
},
});
const rulesBySeasonId = new Map<string, ScoringRules>(
seasonRows.map((s) => [s.id, {
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
}])
);
for (const seasonId of seasonIds) {
const teamId = teamBySeasonId.get(seasonId);
if (!teamId) continue;
const rules = rulesBySeasonId.get(seasonId);
if (!rules) continue;
const delta =
calculateBracketPoints(params.newFloor, rules, params.bracketTemplateId) -
calculateBracketPoints(params.oldFloor, rules, params.bracketTemplateId);
if (delta <= 0) continue;
try {
await recordTeamScoreEvent(
{
teamId,
seasonId,
scoringEventId: params.eventId,
scoringEventName: params.eventName,
sportName,
participantIds: [params.participantId],
pointsDelta: delta,
matchId: params.matchId,
},
db
);
} catch (err) {
logger.error(
`[TeamScoreEvents] Failed to record match score event for team ${teamId} match ${params.matchId}:`,
err
);
}
}
}
export interface TeamScoreEventEntry {
id: string;
teamId: string;
teamName: string;
scoringEventId: string | null;
scoringEventName: string | null;
sportName: string | null;
pointsDelta: string;
occurredAt: Date;
participants: Array<{ id: string; name: string }>;
}
/**
* Returns the most recent scoring events for a league season, ordered by
* occurredAt DESC. Participant names are fetched from the stored participantIds.
*/
export async function getRecentTeamScoreEvents(
seasonId: string,
limit = 10,
providedDb?: ReturnType<typeof database>
): Promise<TeamScoreEventEntry[]> {
const db = providedDb || database();
const rows = await db.query.teamScoreEvents.findMany({
where: eq(schema.teamScoreEvents.seasonId, seasonId),
with: {
team: { columns: { id: true, name: true } },
},
orderBy: [desc(schema.teamScoreEvents.occurredAt)],
limit,
});
if (rows.length === 0) return [];
// Batch-fetch participant names for all stored participant IDs
const allParticipantIds = [
...new Set(rows.flatMap((r) => r.participantIds ?? [])),
];
const participantNameById = new Map<string, string>();
if (allParticipantIds.length > 0) {
const participantRows = await db.query.participants.findMany({
where: inArray(schema.participants.id, allParticipantIds),
columns: { id: true, name: true },
});
for (const p of participantRows) {
participantNameById.set(p.id, p.name);
}
}
return rows.map((row) => ({
id: row.id,
teamId: row.teamId,
teamName: row.team.name,
scoringEventId: row.scoringEventId,
scoringEventName: row.scoringEventName,
sportName: row.sportName,
pointsDelta: row.pointsDelta,
occurredAt: row.occurredAt,
participants: (row.participantIds ?? [])
.map((id) => {
const name = participantNameById.get(id);
return name ? { id, name } : null;
})
.filter((p): p is { id: string; name: string } => p !== null),
}));
}

View file

@ -16,6 +16,8 @@ import { NavigationProgress } from "~/components/NavigationProgress";
import { ClerkProvider } from "@clerk/react-router";
import { dark } from "@clerk/themes";
import { Toaster } from "~/components/ui/sonner";
import { BracktGradients } from "~/components/ui/BracktGradients";
import { Footer } from "~/components/marketing/Footer";
import { isUserAdminByClerkId } from "~/models/user";
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
@ -35,11 +37,11 @@ export async function loader(args: Route.LoaderArgs) {
}
export const links: Route.LinksFunction = () => [
{ rel: "icon", href: "/favicon.ico", sizes: "48x48" },
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
{ rel: "icon", href: "/favicon-96x96.png", type: "image/png", sizes: "96x96" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico?v=20260402", sizes: "48x48" },
{ rel: "icon", href: "/favicon.svg?v=20260402", type: "image/svg+xml" },
{ rel: "icon", href: "/favicon-96x96.png?v=20260402", type: "image/png", sizes: "96x96" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png?v=20260402" },
{ rel: "manifest", href: "/site.webmanifest?v=20260402" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
@ -48,7 +50,7 @@ export const links: Route.LinksFunction = () => [
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
href: "https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap",
},
];
@ -62,6 +64,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links />
</head>
<body>
<BracktGradients />
{children}
<ScrollRestoration />
<Scripts />
@ -81,9 +84,12 @@ export default function App({ loaderData }: Route.ComponentProps) {
{isDraftRoute ? (
<Outlet />
) : (
<main>
<Outlet />
</main>
<>
<main>
<Outlet />
</main>
<Footer />
</>
)}
<Toaster />
</ClerkProvider>

View file

@ -53,6 +53,7 @@ export default [
route("rules", "routes/rules.tsx"),
route("support", "routes/support.tsx"),
route("upcoming-events", "routes/upcoming-events.tsx"),
route("privacy-policy", "routes/privacy-policy.tsx"),
route("test-socket", "routes/test-socket.tsx"),
// Admin routes

View file

@ -421,7 +421,9 @@ export async function action({ request, params }: Route.ActionArgs) {
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
@ -615,15 +617,19 @@ export async function action({ request, params }: Route.ActionArgs) {
});
for (const match of sortedMatches) {
if (!match.winnerId || !match.loserId) continue;
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
await processMatchResult(
{
round: match.round,
winnerId: match.winnerId ?? "",
loserId: match.loserId ?? "",
winnerId: match.winnerId,
loserId: match.loserId,
isScoring,
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId: match.id,
skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
}

View file

@ -1,26 +1,26 @@
import { useEffect } from "react";
import { Link, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { getAuth } from "@clerk/react-router/server";
import { addDays, subDays } from "date-fns";
import type { Route } from "./+types/home";
import { LandingPage } from "~/components/marketing/LandingPage";
import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league";
import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season";
import { findTeamByOwnerAndSeason } from "~/models/team";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getTeamStanding } from "~/models/standings";
import { getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import { getTeamForPick } from "~/lib/draft-order";
import type { CalendarPanelEvent } from "~/components/sport-season/UpcomingCalendarPanel";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
import { MyLeaguesCard } from "~/components/league/MyLeaguesCard";
import { CreateLeagueCard } from "~/components/league/CreateLeagueCard";
import type { LeagueRowProps } from "~/components/league/LeagueRow";
import { toEventSortKey } from "~/lib/date-utils";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
export function meta() {
return [
@ -39,14 +39,12 @@ export async function loader(args: Route.LoaderArgs) {
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
}
// Fetch leagues where user has a team in the current season
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
const today = new Date();
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
// Fetch season details and calendar events in parallel per league
const leaguesWithData = await Promise.all(
leagues.map(async (league) => {
const [season, seasonWithSports] = await Promise.all([
@ -54,16 +52,59 @@ export async function loader(args: Route.LoaderArgs) {
findCurrentSeasonWithSports(league.id),
]);
// Build calendar events for this league
const numSports = seasonWithSports?.seasonSports?.length ?? 0;
const calendarEvents: CalendarPanelEvent[] = [];
let currentRank: number | undefined;
let totalPoints: number | undefined;
let previousRank: number | undefined;
let completionPercentage = 0;
let picksUntilMyTurn: number | undefined;
let draftPosition: number | undefined;
if (league.currentSeasonId && seasonWithSports?.seasonSports) {
const myTeam = await findTeamByOwnerAndSeason(userId, league.currentSeasonId);
if (myTeam) {
const participantsBySportsSeason = await getDraftedParticipantsBySportsSeason(
myTeam.id,
league.currentSeasonId
);
// Fetch standing, completion %, and calendar events in parallel
const [standing, pct, participantsBySportsSeason] = await Promise.all([
getTeamStanding(myTeam.id, league.currentSeasonId),
getSeasonCompletionPercentage(league.currentSeasonId),
getDraftedParticipantsBySportsSeason(myTeam.id, league.currentSeasonId),
]);
completionPercentage = pct;
// Draft slot info — needed for pre-draft position and in-progress picks-until-turn
if (season?.status === "draft" || season?.status === "pre_draft") {
const draftSlots = await findDraftSlotsBySeasonId(league.currentSeasonId);
const mySlot = draftSlots.find((s) => s.teamId === myTeam.id);
if (mySlot) {
draftPosition = mySlot.draftOrder;
}
if (season.status === "draft" && season.currentPickNumber) {
const currentPick = season.currentPickNumber;
let offset = 0;
// Snake draft: one full cycle is draftSlots.length * 2 picks
while (offset < draftSlots.length * 2) {
const slot = getTeamForPick(currentPick + offset, draftSlots);
if (slot?.teamId === myTeam.id) {
picksUntilMyTurn = offset;
break;
}
offset++;
}
}
}
if (standing) {
currentRank = standing.currentRank ?? undefined;
totalPoints = standing.totalPoints ?? undefined;
previousRank = standing.previousRank ?? undefined;
} else if (myTeam && season?.status === "active") {
// No standing row yet (no scoring events) — default to rank 1, 0 points
currentRank = 1;
totalPoints = 0;
}
const perSeasonEvents = await Promise.all(
seasonWithSports.seasonSports.map(async ({ sportsSeason: ss }) => {
@ -96,6 +137,14 @@ export async function loader(args: Route.LoaderArgs) {
return {
...league,
currentSeason: season,
numSports,
currentRank,
totalPoints,
previousRank,
completionPercentage,
picksUntilMyTurn,
draftPosition,
draftDateTime: season?.draftDateTime?.toISOString() ?? null,
calendarEvents,
};
})
@ -112,6 +161,12 @@ export async function loader(args: Route.LoaderArgs) {
};
}
const VALID_STATUSES = ["draft", "active", "pre_draft", "completed"] as const;
type ValidStatus = (typeof VALID_STATUSES)[number];
function isValidStatus(s: string): s is ValidStatus {
return VALID_STATUSES.includes(s as ValidStatus);
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { leagues, isLoggedIn, upcomingCalendarEvents } = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
@ -128,90 +183,54 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}, [searchParams, setSearchParams]);
if (!isLoggedIn) {
return (
<div className="container mx-auto py-16 px-4 text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to Brackt</h1>
<p className="text-xl text-muted-foreground mb-8">
Please sign in to view your leagues
</p>
</div>
);
return <LandingPage />;
}
const leagueRows: LeagueRowProps[] = leagues.map((league) => {
const rawStatus = league.currentSeason?.status ?? "pre_draft";
return {
leagueId: league.id,
leagueName: league.name,
numSports: league.numSports,
status: isValidStatus(rawStatus) ? rawStatus : "pre_draft",
seasonId: league.currentSeasonId ?? undefined,
currentRank: league.currentRank,
totalPoints: league.totalPoints,
previousRank: league.previousRank,
completionPercentage: league.completionPercentage,
picksUntilMyTurn: league.picksUntilMyTurn,
draftPosition: league.draftPosition,
draftDateTime: league.draftDateTime,
};
});
return (
<div className="container mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-8">
<h1 className="text-4xl font-bold">My Leagues</h1>
<Button asChild>
<Link to="/leagues/new">Create New League</Link>
</Button>
{/*
lg+: 3-col grid, left column (My Leagues + Create) spans 2, right (Events) spans 1.
mobile/tablet: single column stacked in order: My Leagues, Upcoming Events, Create League.
*/}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* My Leagues — order 1 on mobile, col-span-2 on desktop */}
<div className="order-1 lg:col-span-2">
<MyLeaguesCard leagues={leagueRows} />
</div>
{/* Upcoming Events — order 2 on mobile, right column on desktop spanning both rows */}
<div className="order-2 lg:col-span-1 lg:row-span-2">
<UpcomingEventsCard
events={upcomingCalendarEvents}
limit={8}
viewAllUrl="/upcoming-events"
showLeagueAvatar={leagueRows.length > 1}
/>
</div>
{/* Create a League — order 3 on mobile, below My Leagues on desktop */}
<div className="order-3 lg:col-span-2">
<CreateLeagueCard />
</div>
</div>
{upcomingCalendarEvents.length > 0 && (
<div className="mb-8">
<UpcomingCalendarPanel
events={upcomingCalendarEvents}
showLeague={true}
limit={6}
viewAllUrl="/upcoming-events"
/>
</div>
)}
{leagues.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Leagues</CardTitle>
<CardDescription>
You aren't a member of any leagues yet
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/leagues/new">Create a New League</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{leagues.map((league) => (
<Link key={league.id} to={`/leagues/${league.id}`}>
<Card className="hover:border-primary transition-colors cursor-pointer h-full">
<CardHeader>
<CardTitle>{league.name}</CardTitle>
<CardDescription>
Created {new Date(league.createdAt).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent>
{league.currentSeason ? (
<div className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">
Current Season
</p>
<p className="font-medium">
{league.currentSeason.year}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium capitalize">
{league.currentSeason.status.replace("_", " ")}
</p>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
No active season
</p>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}

View file

@ -1,4 +1,5 @@
import { Link } from "react-router";
import { Button } from "app/components/ui/button";
export function meta() {
return [
@ -27,8 +28,8 @@ export default function HowToPlay() {
<section>
<h2 className="text-2xl font-semibold mb-3">The Big Picture</h2>
<p className="text-muted-foreground">
Before the season starts, every participant in your league holds a
draft picking real athletes and teams across a set of sports
Before the season starts, everyone in the league participates in a
draft by picking real teams (and athletes in individual sports) across a set of sports
chosen by your commissioner. Over the following months, those picks
compete in their actual seasons. When it's all said and done,
whoever earned the most points from their picks wins.
@ -39,17 +40,19 @@ export default function HowToPlay() {
<section>
<h2 className="text-2xl font-semibold mb-3">Building Your Roster</h2>
<p className="text-muted-foreground mb-4">
Your league includes a set of sports selected by the commissioner
think NFL, NBA, golf, Formula 1, tennis, and more. Your roster has
Your league includes a set of sports selected by the commissioner, such as
the NFL, NBA, Golf, Formula 1, Tennis, and more. Your roster has
one dedicated spot for each sport, so you need to draft at least
one pick from every sport in the league.
</p>
<p className="text-muted-foreground mb-4">
Extra draft rounds beyond the number of sports create{" "}
<span className="font-semibold text-foreground">flex spots</span>.
Flex picks can come from any sport great for grabbing a second
player in a sport where you see value, or doubling down on a safe
bet.
Flex picks can come from any sport. These are great for grabbing a
second (or even a third and a fourth!) player in a sport where you
see value, or doubling down on a safe bet. How you use your flex
spots can be the difference between winning the league and
finishing at the bottom of the table.
</p>
<div className="bg-muted rounded-lg p-4 text-sm text-muted-foreground">
<p>
@ -65,7 +68,7 @@ export default function HowToPlay() {
<section>
<h2 className="text-2xl font-semibold mb-3">How Points Work</h2>
<p className="text-muted-foreground mb-4">
Points are awarded based on where your picks finish 1st through
Points are awarded based on where your picks finish: 1st through
8th. The better they finish, the more you earn. Your commissioner
can adjust point values before the draft, but here's what a typical
league looks like:
@ -124,24 +127,27 @@ export default function HowToPlay() {
</div>
<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. Drafting rare picks can pay
off.
those points aren't awarded to anyone in your league. In other
words, if no one picks Indiana to win the NCAA Football
championship, the manager who drafted Miami, the team that lost to
them, still only would receive the 2nd place points.
</p>
</section>
{/* Major-Based Sports */}
<section>
<h2 className="text-2xl font-semibold mb-3">
Golf, Tennis, CS2 Sports with Majors
</h2>
<h2 className="text-2xl font-semibold mb-3">Sports with Majors</h2>
<p className="text-muted-foreground mb-4">
Some sports don't have a single championship they have multiple
major tournaments spread throughout the year. For these sports,
Some sports don't have a single championship, they have multiple
major tournaments spread throughout the year. Classic examples are
golf and tennis, but other sports may be scored by majors as well.
You can see on your league pages how each sport is scored. For
these sports with majors,
players earn{" "}
<span className="font-semibold text-foreground">
qualifying points (QP)
</span>{" "}
at each major:
at each one:
</p>
<div className="bg-muted rounded-lg overflow-hidden mb-4">
<table className="w-full text-sm">
@ -215,71 +221,89 @@ export default function HowToPlay() {
</p>
</section>
{/* The Draft */}
{/* The Draft Room */}
<section>
<h2 className="text-2xl font-semibold mb-3">The Draft</h2>
<h2 className="text-2xl font-semibold mb-3">The Draft Room</h2>
<p className="text-muted-foreground mb-6">
The draft is a snake draft pick order reverses every round. If
you pick last in round 1, you pick first in round 2. No trading
picks, and once you make a selection it's yours for the whole
season.
Because Brackt is a "draft and done" league, the draft is your only
opportunity to take action. Once the final pick is made, your roster
is locked for the entire year. There are no trades, waivers, or
mid-season pickups. Your success depends entirely on the strategy
you execute during the draft.
</p>
{/* Chess Clock */}
<div className="border rounded-lg p-5 mb-6 space-y-3">
<h3 className="text-lg font-semibold">
The Chess Clock A Brackt Original
</h3>
{/* Timing Mechanics */}
<div className="space-y-3 mb-6">
<h3 className="text-lg font-semibold">Timing Mechanics</h3>
<p className="text-muted-foreground">
Most fantasy drafts use a simple countdown: you get X seconds per
pick and that's it. Brackt does it differently with a{" "}
<span className="font-semibold text-foreground">
Fischer increment timer
</span>
, borrowed from competitive chess.
The draft uses a snake format where the pick order reverses every
round. You can configure the timing of the draft using one of two
primary mechanics:
</p>
<p className="text-muted-foreground">
Every participant starts the draft with a personal time bank.
Your clock counts down while it's your turn. When you make a
pick,{" "}
<span className="font-semibold text-foreground">
time is added back
</span>{" "}
to your bank. Any time you don't use carries over to your next
pick.
</p>
<p className="text-muted-foreground">
Pick quickly early on and you'll build up a bigger bank for the
later rounds when decisions get harder. Deliberate too long and
your bank shrinks. It rewards decisiveness without penalizing you
for taking a moment when you really need it.
</p>
<div className="bg-muted rounded-md p-3 text-sm text-muted-foreground">
<span className="font-semibold text-foreground">Example:</span>{" "}
On Standard speed, you start with 2 minutes. You make your first
pick in 30 seconds 15 seconds are added back, leaving you with
1 minute 45 seconds for your next turn. Keep picking fast and
that bank grows.
</div>
<ul className="space-y-2 text-muted-foreground pl-4">
<li>
<span className="font-semibold text-foreground">
Standard Clock:
</span>{" "}
A traditional fixed countdown (such as 60 seconds per pick). It
is a straightforward and consistent pace.
</li>
<li>
<span className="font-semibold text-foreground">
Chess Clock (Fischer Increment):
</span>{" "}
This system uses a Time Bank and an Increment. You start with a
pool of time (for example, 2 minutes). Each time you make a
pick, a set bonus like 15 seconds is added back to your bank.
This allows you to pick quickly in the early rounds to save up
time for more difficult decisions later on.
</li>
</ul>
</div>
{/* Draft Queue & Autodraft */}
<div className="space-y-3">
<h3 className="text-lg font-semibold">Draft Queue & Autodraft</h3>
{/* Flexible Pacing */}
<div className="space-y-3 mb-6">
<h3 className="text-lg font-semibold">Flexible Pacing</h3>
<p className="text-muted-foreground">
Before and during the draft you can build a{" "}
<span className="font-semibold text-foreground">draft queue</span>{" "}
an ordered list of picks you want to make. When it's your turn,
you can pick manually or let your queue guide you.
These mechanics can be applied to any draft speed. Whether you
want to finish in an hour or over the course of a week, the system
scales to your needs:
</p>
<ul className="space-y-2 text-muted-foreground pl-4">
<li>
<span className="font-semibold text-foreground">
Live Drafts:
</span>{" "}
Uses short timers (seconds or minutes) for a fast-paced,
single-session event.
</li>
<li>
<span className="font-semibold text-foreground">
Slow Drafts:
</span>{" "}
Uses extended timers (hours) for leagues that want to draft over
several days. Slow drafts include a customizable{" "}
<span className="font-semibold text-foreground">
Overnight Pause
</span>
. The commissioner sets a uniform window (such as 10:00 PM to
8:00 AM) where the clock stops for everyone. This ensures no one
is forced to make a pick in the middle of the night.
</li>
</ul>
</div>
{/* Queue and Autodraft */}
<div className="space-y-3">
<h3 className="text-lg font-semibold">Queue and Autodraft</h3>
<p className="text-muted-foreground">
If your time bank runs out,{" "}
<span className="font-semibold text-foreground">autodraft</span>{" "}
kicks in automatically. It'll grab the first available pick from
your queue. If your queue is empty or everything in it has
already been taken, it'll grab from the top of the available player pool.
Keep your queue updated and you'll never get stuck with a random
pick.
To keep the draft moving regardless of the pace, you can use the{" "}
<span className="font-semibold text-foreground">Draft Queue</span>{" "}
to pre-rank your targets. If your clock expires, the system
automatically selects the highest available player from your
queue. If your queue is empty, the system will select the
highest-ranked athlete remaining in the pool to ensure the draft
never stalls.
</p>
</div>
</section>
@ -300,56 +324,21 @@ export default function HowToPlay() {
</p>
</section>
{/* Tips */}
<section className="bg-primary/5 p-6 rounded-lg border border-primary/20">
<h2 className="text-xl font-semibold mb-4">Quick Tips</h2>
<ul className="space-y-3 text-muted-foreground">
<li>
<span className="font-semibold text-foreground">
Know your sports.
</span>{" "}
Understanding how each sport determines its top 8 (playoffs vs.
regular season vs. bracket) is the biggest edge you can have.
</li>
<li>
<span className="font-semibold text-foreground">
Build your queue early.
</span>{" "}
The draft moves fast. Having a ranked queue ready means you'll
always get a pick you're happy with, even if you step away.
</li>
<li>
<span className="font-semibold text-foreground">
Pick fast early on.
</span>{" "}
The chess clock rewards quick decisions. Bank extra time for the
tricky later rounds.
</li>
<li>
<span className="font-semibold text-foreground">
Flex spots are strategic.
</span>{" "}
Use them to double up on sports you feel confident about, not
just to fill the roster.
</li>
</ul>
</section>
{/* Links */}
<section className="flex flex-col sm:flex-row gap-4 items-center justify-between pt-2">
<p className="text-muted-foreground">
Want the full breakdown? Read the{" "}
<Link to="/rules" className="underline underline-offset-4 hover:text-foreground transition-colors">
<Link
to="/rules"
className="underline underline-offset-4 hover:text-foreground transition-colors"
>
official rules
</Link>
.
</p>
<Link
to="/leagues/new"
className="inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-11 px-8 text-base whitespace-nowrap"
>
Create a League
</Link>
<Button asChild size="lg">
<Link to="/leagues/new">Create a League</Link>
</Button>
</section>
</div>

View file

@ -1,5 +1,5 @@
import { useLoaderData } from "react-router";
import { eq, asc, and } from "drizzle-orm";
import { useLoaderData, Link } from "react-router";
import { eq, asc, and, inArray } from "drizzle-orm";
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -7,6 +7,10 @@ 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 type { CoronaState } from "~/components/draft/DraftPickCell";
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -92,6 +96,7 @@ export async function loader(args: Route.LoaderArgs) {
team: schema.teams,
participant: schema.participants,
sport: schema.sports,
scoringPattern: schema.sportsSeasons.scoringPattern,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
@ -109,16 +114,113 @@ export async function loader(args: Route.LoaderArgs) {
const ownerMap = await buildOwnerMap(draftSlots);
const 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 results = await db
.select({
participantId: schema.participantResults.participantId,
sportsSeasonId: schema.participantResults.sportsSeasonId,
finalPosition: schema.participantResults.finalPosition,
isPartialScore: schema.participantResults.isPartialScore,
})
.from(schema.participantResults)
.where(inArray(schema.participantResults.participantId, participantIds));
const resultByParticipant = new Map(
results.map((r) => [r.participantId, r])
);
const maxPoints = season.pointsFor1st;
const bracketTemplateBySportsSeason = new Map<string, string | null>();
if (sportsSeasonIds.length > 0) {
const events = await db
.select({
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
bracketTemplateId: schema.scoringEvents.bracketTemplateId,
})
.from(schema.scoringEvents)
.where(inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds));
for (const ev of events) {
if (!bracketTemplateBySportsSeason.has(ev.sportsSeasonId)) {
bracketTemplateBySportsSeason.set(
ev.sportsSeasonId,
ev.bracketTemplateId
);
}
}
}
const scoringRules = {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
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" };
}
}
return {
season,
draftSlots,
draftPicks,
ownerMap,
coronaStates,
};
}
export default function DraftBoard() {
const { season, draftSlots, draftPicks: initialPicks, ownerMap } = useLoaderData<typeof loader>();
const { season, draftSlots, draftPicks: initialPicks, ownerMap, coronaStates } = useLoaderData<typeof loader>();
const { isConnected, on, off } = useDraftSocket(season.id);
const [picks, setPicks] = useState(initialPicks);
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
@ -157,36 +259,46 @@ export default function DraftBoard() {
draftGrid.push(roundPicks);
}
const isDraftActive = season.status === "draft";
const currentRound = totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1;
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b bg-card sticky top-0 z-10">
<div className="w-full px-4 py-4">
<div className="w-full px-4 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">
{season.league.name} - {season.year} Draft Board
<div className="flex items-center gap-3">
<Link to="/">
<img src="/logomark.svg" alt="Brackt" className="h-8" />
</Link>
<h1 className="text-2xl font-bold">
{season.league.name} Draft Board
</h1>
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
<span>Round: {totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1}</span>
<span>Pick: {currentPick}</span>
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</div>
</div>
{season.status === "draft" && (
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
isConnected ? "bg-emerald-500" : "bg-coral-accent"
}`}
/>
<span className="text-sm font-medium">
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
)}
<div className="flex items-center gap-4">
{isDraftActive && (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>Round {currentRound}</span>
<span>Pick {currentPick}</span>
<div className="flex items-center gap-2">
<div
className={`w-2.5 h-2.5 rounded-full ${
isConnected ? "bg-emerald-500" : "bg-coral-accent"
}`}
/>
<span className="font-medium">
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
</div>
)}
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${season.leagueId}`}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to League
</Link>
</Button>
</div>
</div>
</div>
</div>
@ -198,6 +310,9 @@ export default function DraftBoard() {
draftGrid={draftGrid}
currentPick={currentPick}
ownerMap={ownerMap}
coronaStates={coronaStates}
seasonStatus={season.status}
draftPaused={season.draftPaused}
/>
</div>
</div>

View file

@ -7,7 +7,7 @@ import { useDraftSocket } from "~/hooks/useDraftSocket";
import { logger } from "~/lib/logger";
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { getAuth } from "@clerk/react-router/server";
import { getTeamQueue } from "~/models/draft-queue";
@ -18,6 +18,7 @@ import { DraftSummaryView } from "~/components/draft/DraftSummaryView";
import { QueueSection } from "~/components/draft/QueueSection";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
import { RecentPicksFeed } from "~/components/draft/RecentPicksFeed";
import { DraftGridSection } from "~/components/draft/DraftGridSection";
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay";
@ -27,7 +28,7 @@ import { getTeamForPick } from "~/lib/draft-order";
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { NotificationSettings } from "~/components/NotificationSettings";
import { AutodraftSettings, getAutodraftLabel } from "~/components/AutodraftSettings";
import { AutodraftSettings, getAutodraftLabel, AutodraftBadgeWithPopover } from "~/components/AutodraftSettings";
import { toast } from "sonner";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
@ -46,7 +47,7 @@ type AutodraftStatusEntry = {
};
const MOBILE_TABS_BASE = [
{ id: "available" as const, label: "Available", Icon: Users },
{ id: "available" as const, label: "Participants", Icon: Users },
{ id: "board" as const, label: "Board", Icon: LayoutGrid },
{ id: "teams" as const, label: "Teams", Icon: ListChecks },
{ id: "controls" as const, label: "Controls", Icon: Settings },
@ -539,11 +540,12 @@ export default function DraftRoom() {
const stored = localStorage.getItem("draftSidebarCollapsed");
return stored ? JSON.parse(stored) : false;
});
const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("board");
const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("participants");
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">(
!userTeam && isCommissioner ? "board" : "available"
);
const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters");
const [boardSubTab, setBoardSubTab] = useState<"board" | "pick-list">("board");
const isMobile = useMediaQuery("(max-width: 767px)");
// Track autodraft status for all teams
@ -643,6 +645,17 @@ export default function DraftRoom() {
);
}, [availableParticipants]);
const participantRanks = useMemo(() => {
const ranks = new Map<string, { overallRank: number; sportRank: number }>();
const sportCounters = new Map<string, number>();
availableParticipants.forEach((p, i) => {
const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1;
sportCounters.set(p.sport.id, sportIdx);
ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx });
});
return ranks;
}, [availableParticipants]);
// Calculate draft eligibility for current user's team
const eligibility = useMemo(() => {
if (!userTeam) return null;
@ -1384,6 +1397,7 @@ export default function DraftRoom() {
const draftGrid = useMemo(() => {
const teamCount = draftSlots.length;
const rounds = season.draftRounds;
const pickMap = new Map(picks.map((p) => [p.pickNumber, p]));
const grid: Array<
Array<{
pickNumber: number;
@ -1404,7 +1418,7 @@ export default function DraftRoom() {
const pickNumber = (round - 1) * teamCount + pickInRound;
const teamId = draftSlots[teamIndex]?.team.id;
const pick = picks.find((p) => p.pickNumber === pickNumber);
const pick = pickMap.get(pickNumber);
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
}
@ -1491,8 +1505,26 @@ export default function DraftRoom() {
}
}, [userDraftedSportNames]);
const availableParticipantsSectionProps = {
const miniDraftGrid = useMemo(() => ({
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap,
teamTimers,
autodraftStatus,
seasonStatus: season.status,
draftPaused: isPaused,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, season.status, isPaused, isCommissioner, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]);
const availableParticipantsSectionProps = useMemo(() => ({
participants: filteredParticipants,
participantRanks,
miniDraftGrid,
searchQuery,
sportFilters,
hideDrafted,
@ -1513,7 +1545,7 @@ export default function DraftRoom() {
onMakePick: handleMakePick,
onAddToQueue: handleAddToQueue,
onRemoveFromQueue: handleRemoveFromQueue,
};
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue]);
const draftGridSectionProps = {
draftSlots,
@ -1524,6 +1556,8 @@ export default function DraftRoom() {
connectedTeams,
isCommissioner,
ownerMap,
seasonStatus: season.status,
draftPaused: isPaused,
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
onForceAutopick: handleForceAutopick,
@ -1545,6 +1579,7 @@ export default function DraftRoom() {
picks,
sports: seasonSportsData,
ownerMap,
totalRounds: season.draftRounds,
};
const handleAutodraftUpdate = useCallback(
@ -1558,13 +1593,8 @@ export default function DraftRoom() {
? {
queue,
availableParticipants,
seasonId: season.id,
teamId: userTeam.id,
isMyTurn,
canPick,
userAutodraft,
onRemoveFromQueue: handleRemoveFromQueue,
onAutodraftUpdate: handleAutodraftUpdate,
onReorder: handleReorderQueue,
onMakePick: handleMakePick,
}
@ -1626,20 +1656,25 @@ export default function DraftRoom() {
</svg>
</Button>
)}
<div>
<h1 className="text-lg md:text-3xl font-bold">
{season.league.name} - {season.year} Draft
</h1>
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
<span>Round: {currentRound}</span>
<span>Pick: {currentPick}</span>
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</div>
</div>
<Link to="/">
<img src="/logomark.svg" alt="Brackt" className="h-8" />
</Link>
<h1 className="text-lg md:text-xl font-bold">
Draft Room
</h1>
</div>
<div className="flex items-center gap-3">
<div className="hidden md:flex items-center gap-2 relative">
<span className="text-xs text-muted-foreground">Pick Notifications</span>
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
switchOnly
/>
</div>
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */}
{isCommissioner && season.status === "pre_draft" && (
<Button className="hidden md:flex" onClick={handleStartDraft}>Start Draft</Button>
@ -1659,16 +1694,6 @@ export default function DraftRoom() {
</div>
)}
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
isConnected ? "bg-emerald-500" : "bg-coral-accent"
}`}
/>
<span className="hidden sm:inline text-sm font-medium">
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
<Button variant="outline" asChild className="hidden md:flex">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
@ -1705,12 +1730,51 @@ export default function DraftRoom() {
</div>
)}
{/* Desktop Tab Navigation Row */}
<div className={`hidden md:flex flex-shrink-0 items-center justify-between px-4 py-2 border-b transition-all duration-300 ${
isMyTurn && season.status === "draft" && !isDraftComplete
? "bg-electric/25 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
: "bg-card"
}`}>
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "participants" | "board" | "rosters" | "summary")
}
>
<TabsList>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="rosters">Rosters</TabsTrigger>
<TabsTrigger value="summary">Summary</TabsTrigger>
</TabsList>
</Tabs>
{userTeam && (
<AutodraftBadgeWithPopover
seasonId={season.id}
teamId={userTeam.id}
isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn}
onUpdate={handleAutodraftUpdate}
/>
)}
</div>
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
<div className="flex-1 overflow-hidden">
{isMobile ? (
<div className="flex h-full overflow-hidden flex-col">
{mobileTab === "available" && (
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0">
<RecentPicksFeed picks={picks} />
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<AvailableParticipantsSection {...availableParticipantsSectionProps} miniDraftGrid={undefined} />
</div>
</div>
)}
{mobileTab === "queue" && (
<div className="h-full overflow-y-auto">
@ -1724,7 +1788,39 @@ export default function DraftRoom() {
</div>
)}
{mobileTab === "board" && (
<DraftGridSection {...draftGridSectionProps} />
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0 flex gap-1 p-2 border-b">
<button
onClick={() => setBoardSubTab("board")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
boardSubTab === "board"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Board
</button>
<button
onClick={() => setBoardSubTab("pick-list")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
boardSubTab === "pick-list"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Pick List
</button>
</div>
<div className="flex-1 overflow-hidden">
{boardSubTab !== "pick-list" ? (
<DraftGridSection {...draftGridSectionProps} />
) : (
<div className="h-full overflow-y-auto p-4">
<SidebarRecentPicks picks={picks} />
</div>
)}
</div>
</div>
)}
{mobileTab === "teams" && (
<div className="flex flex-col h-full overflow-hidden">
@ -1778,19 +1874,6 @@ export default function DraftRoom() {
)
)}
<section>
<h2 className="font-semibold text-sm mb-2">Recent Picks</h2>
<SidebarRecentPicks picks={picks} />
</section>
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
/>
<Button variant="outline" asChild className="w-full min-h-[48px]">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
@ -1805,84 +1888,22 @@ export default function DraftRoom() {
onCollapsedChange={setSidebarCollapsed}
queueSection={<QueueSection {...queueSectionProps} />}
recentPicksSection={<SidebarRecentPicks picks={picks} />}
settingsSection={
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
/>
}
/>
)}
<div className="flex-1 overflow-hidden">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "participants" | "board" | "rosters" | "summary")
}
className="h-full flex flex-col"
>
<div className={`mt-4 transition-all duration-300 ${
isMyTurn && season.status === "draft" && !isDraftComplete
? "bg-electric/25 border-y-2 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
: ""
}`}>
<div className="flex items-center gap-3 px-4 py-2">
<TabsList>
<TabsTrigger value="participants">Available Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="rosters">Rosters</TabsTrigger>
<TabsTrigger value="summary">Summary</TabsTrigger>
</TabsList>
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
<div className={`ml-auto flex items-center gap-2.5 rounded-lg px-3 py-1.5 flex-shrink-0 transition-all ${
isMyTurn
? "bg-electric/30 border-2 border-electric shadow-md shadow-electric/30"
: "border border-border/50"
}`}>
<div className="leading-tight">
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
{isMyTurn ? "Your Turn!" : "On the Clock"}
</div>
<div className={`text-sm font-bold ${isMyTurn ? "text-foreground" : "text-muted-foreground"}`}>
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
</div>
{currentDraftSlotOwnerName && (
<div className="text-xs text-muted-foreground">{currentDraftSlot.team.name}</div>
)}
</div>
{isPaused ? (
<span className="text-sm font-bold text-amber-accent">Paused</span>
) : (
<span className={`text-2xl font-mono font-bold tabular-nums ${currentClockColor}`}>
{formatClockTime(currentClockTime)}
</span>
)}
</div>
)}
</div>
</div>
<TabsContent value="participants" className="flex-1 overflow-hidden m-0">
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
</TabsContent>
<TabsContent value="board" className="flex-1 overflow-hidden m-0">
<DraftGridSection {...draftGridSectionProps} />
</TabsContent>
<TabsContent value="rosters" className="flex-1 overflow-hidden m-0">
<TeamRosterView {...rosterViewProps} />
</TabsContent>
<TabsContent value="summary" className="flex-1 overflow-hidden m-0">
<DraftSummaryView {...summaryViewProps} />
</TabsContent>
</Tabs>
<div className="flex-1 overflow-hidden relative">
<div className={`absolute inset-0 ${activeTab === "participants" ? "" : "hidden"}`}>
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "board" ? "" : "hidden"}`}>
<DraftGridSection {...draftGridSectionProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "rosters" ? "" : "hidden"}`}>
<TeamRosterView {...rosterViewProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "summary" ? "" : "hidden"}`}>
<DraftSummaryView {...summaryViewProps} />
</div>
</div>
</div>
)}

View file

@ -15,7 +15,7 @@ import { findCurrentSeasonWithSports } from "~/models/season";
import { getSeasonStandings } from "~/models/standings";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick";
import { getAuditLogForSeason } from "~/models/audit-log";
import type { Route } from "./+types/$leagueId";
@ -112,9 +112,15 @@ export async function loader(args: Route.LoaderArgs) {
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
const participantsBySportsSeason = myTeam && season
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
: new Map<string, Array<{ id: string; name: string }>>();
const [participantsBySportsSeason, participantsWithPoints]: [
Map<string, Array<{ id: string; name: string }>>,
Map<string, DraftedParticipantWithPoints[]>
] = myTeam && season
? await Promise.all([
getDraftedParticipantsBySportsSeason(myTeam.id, season.id),
getDraftedParticipantsWithPoints(myTeam.id, season.id),
])
: [new Map(), new Map()];
const dateFrom = new Date(dateFromStr + "T00:00:00.000Z");
const dateTo = new Date(dateToStr + "T23:59:59.999Z");
@ -122,6 +128,7 @@ export async function loader(args: Route.LoaderArgs) {
const sportsSeasons = await Promise.all(
rawSportsSeasons.map(async (ss) => {
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
const draftedParticipantsWithPoints = participantsWithPoints.get(ss.id) ?? [];
const upcomingParticipantEvents =
draftedParticipants.length > 0
? await getUpcomingEventsForDraftedParticipants(
@ -166,6 +173,7 @@ export async function loader(args: Route.LoaderArgs) {
scoringPattern: ss.scoringPattern,
sport: ss.sport,
upcomingParticipantEvents,
draftedParticipantsWithPoints,
};
})
);
@ -179,6 +187,8 @@ export async function loader(args: Route.LoaderArgs) {
sportName: ss.sport.name,
sportSeasonName: ss.name,
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
leagueId,
leagueName: league.name,
}))
)
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));

View file

@ -166,14 +166,15 @@ export async function loader(args: Route.LoaderArgs) {
};
let groupStandings: GroupStandingData[] = [];
let bracketTemplateId: string | null = null;
if (scoringPattern === "playoff_bracket") {
// Fetch playoff matches via scoring events
let templateId: string | undefined;
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
let templateId: string | undefined;
if (events.length > 0) {
const eventIds = events.map((e) => e.id);
const matches = await db.query.playoffMatches.findMany({
@ -188,6 +189,7 @@ export async function loader(args: Route.LoaderArgs) {
playoffMatches = matches as PlayoffMatchWithRelations[];
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
bracketTemplateId = templateId ?? null;
const template = templateId ? getBracketTemplate(templateId) : undefined;
playoffRounds = getOrderedRoundsFromMatches(matches, template);
@ -339,5 +341,6 @@ export async function loader(args: Route.LoaderArgs) {
regularSeasonStandings,
participantEvs,
groupStandings,
bracketTemplateId,
};
}

View file

@ -1,4 +1,5 @@
import { Link } from "react-router";
import { useState } from "react";
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
@ -7,8 +8,8 @@ import { EventSchedule } from "~/components/sport-season/EventSchedule";
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
import { cn } from "~/lib/utils";
import { ArrowLeft } from "lucide-react";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.sportsSeason?.name ?? "Sport Season"}${data?.league?.name ?? "League"} - Brackt` }];
@ -16,33 +17,7 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export { loader };
function getStatusBadge(status: string) {
switch (status) {
case "active":
return (
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
<Zap className="mr-1 h-3 w-3" />
Active
</Badge>
);
case "upcoming":
return (
<Badge variant="outline" className="bg-electric/15 text-electric border-electric/30">
<Clock className="mr-1 h-3 w-3" />
Upcoming
</Badge>
);
case "completed":
return (
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
);
default:
return null;
}
}
type BracketView = "standings" | "playoffs" | "finished";
export default function SportSeasonDetail({
loaderData,
@ -65,8 +40,9 @@ export default function SportSeasonDetail({
recentEvents,
seasonIsFinalized,
regularSeasonStandings,
participantEvs,
groupStandings,
bracketTemplateId,
} = loaderData;
const hasBracket = playoffMatches && playoffMatches.length > 0;
@ -78,14 +54,9 @@ export default function SportSeasonDetail({
simulatorType === "nhl_bracket" ? "nhl-divisions" :
simulatorType === "mlb_bracket" ? "mlb-divisions" :
"flat";
// NBA: 10 teams make the postseason (6 auto-qualify + 4 play-in)
// AFL: top 10 advance to the finals series (Wildcard + QF/EF + SF + PF + GF)
// NHL: handled by nhl-divisions mode (3 per div + 2 wild cards)
// MLB: handled by mlb-divisions mode (1 per div + 3 wild cards)
const playoffSpots =
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
// Build ownership map for RegularSeasonStandings
const ownershipMap = Object.fromEntries(
teamOwnerships.map((o) => [
o.participantId,
@ -93,6 +64,62 @@ export default function SportSeasonDetail({
])
);
// Show the 3-way toggle only for bracket sports that also have regular season standings
const showToggle = hasStandings && scoringPattern === "playoff_bracket";
const [view, setView] = useState<BracketView>(() => {
if (!hasBracket) return "standings";
if (sportsSeason.status === "completed") return "finished";
return "playoffs";
});
const TOGGLE_VIEWS: { value: BracketView; label: string }[] = [
{ value: "standings", label: "Standings" },
{ value: "playoffs", label: "Playoffs" },
{ value: "finished", label: "Finished" },
];
const bracketDisplay = (bracketMode: "bracket" | "rankings") => (
<SportSeasonDisplay
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
sportSeasonName={sportsSeason.name}
sportName={sportsSeason.sport.name}
bracketMode={bracketMode}
playoffMatches={playoffMatches}
playoffRounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds}
seasonStandings={seasonStandings}
seasonIsFinalized={seasonIsFinalized}
qpStandings={qpStandings}
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
totalMajors={sportsSeason.totalMajors}
majorsCompleted={sportsSeason.majorsCompleted || 0}
canFinalize={
(sportsSeason.majorsCompleted || 0) >=
(sportsSeason.totalMajors || 0) &&
!sportsSeason.qualifyingPointsFinalized
}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
scoringRules={season}
showOwnership={true}
/>
);
const standingsDisplay = (
<RegularSeasonStandings
standings={regularSeasonStandings}
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
);
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-6">
@ -103,7 +130,7 @@ export default function SportSeasonDetail({
</Link>
</Button>
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-4">
<div>
<h1 className="text-3xl font-bold mb-1">
{sportsSeason.sport.name}
@ -112,83 +139,68 @@ export default function SportSeasonDetail({
{sportsSeason.name} {league.name} {season.year} Season
</p>
</div>
{getStatusBadge(sportsSeason.status)}
{showToggle && (
<div className="flex gap-1 rounded-lg bg-muted p-1 sm:shrink-0">
{TOGGLE_VIEWS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setView(value)}
className={cn(
"flex-1 sm:flex-none px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
view === value
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{label}
</button>
))}
</div>
)}
</div>
</div>
{/* Regular season standings — show above bracket when no bracket exists yet */}
{hasStandings && !hasBracket && (
<div className="mb-6">
<RegularSeasonStandings
standings={regularSeasonStandings}
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
{showToggle ? (
<div>
{view === "standings" && standingsDisplay}
{view === "playoffs" && bracketDisplay("bracket")}
{view === "finished" && bracketDisplay("rankings")}
</div>
)}
) : (
<>
{/* Regular season standings above bracket when no bracket exists yet */}
{hasStandings && !hasBracket && (
<div className="mb-6">{standingsDisplay}</div>
)}
{/* Event schedule - hidden for bracket sports (the bracket itself is the event) */}
{scoringPattern !== "playoff_bracket" &&
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
<div className="mb-6">
<EventSchedule
upcomingEvents={upcomingEvents}
recentEvents={recentEvents}
/>
</div>
)}
{/* Event schedule — hidden for bracket sports */}
{scoringPattern !== "playoff_bracket" &&
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
<div className="mb-6">
<EventSchedule
upcomingEvents={upcomingEvents}
recentEvents={recentEvents}
/>
</div>
)}
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */}
{groupStandings.length > 0 && (
<div className="mb-6">
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
</div>
)}
{/* Group stage standings */}
{groupStandings.length > 0 && (
<div className="mb-6">
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
</div>
)}
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
sportSeasonName={sportsSeason.name}
sportName={sportsSeason.sport.name}
playoffMatches={playoffMatches}
playoffRounds={playoffRounds}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds}
seasonStandings={seasonStandings}
seasonIsFinalized={seasonIsFinalized}
qpStandings={qpStandings}
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
totalMajors={sportsSeason.totalMajors}
majorsCompleted={sportsSeason.majorsCompleted || 0}
canFinalize={
(sportsSeason.majorsCompleted || 0) >=
(sportsSeason.totalMajors || 0) &&
!sportsSeason.qualifyingPointsFinalized
}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
scoringRules={season}
showOwnership={true}
/>}
{/* Bracket — hidden when standings exist but bracket is empty */}
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && bracketDisplay("bracket")}
{/* Regular season standings — show below bracket once bracket exists */}
{hasStandings && hasBracket && (
<div className="mt-8">
<RegularSeasonStandings
standings={regularSeasonStandings}
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
</div>
{/* Regular season standings below bracket once bracket exists */}
{hasStandings && hasBracket && (
<div className="mt-8">{standingsDisplay}</div>
)}
</>
)}
</div>
);

View file

@ -4,13 +4,15 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StandingsTable } from "~/components/standings/StandingsTable";
import { logger } from "~/lib/logger";
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user";
import { StandingsPreview } from "~/components/league/StandingsPreview";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
import type { Route } from "./+types/$leagueId.standings.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -73,7 +75,7 @@ export async function loader(args: Route.LoaderArgs) {
}
// Fetch standings, teams (for owner lookup), and independent data in parallel
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage] =
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage, recentScoreEvents] =
await Promise.all([
getSevenDayStandingsChange(seasonId, db),
db.query.teams.findMany({
@ -83,6 +85,7 @@ export async function loader(args: Route.LoaderArgs) {
getSeasonPointProgression(seasonId, db),
isSeasonComplete(seasonId, db),
getSeasonCompletionPercentage(seasonId, db),
getRecentTeamScoreEvents(seasonId, 10, db),
]);
// Build teamId -> ownerName map
@ -105,6 +108,12 @@ export async function loader(args: Route.LoaderArgs) {
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
}));
// Enrich recentScoreEvents with owner names
const enrichedScoreEvents = recentScoreEvents.map((event) => ({
...event,
ownerName: ownerNameByTeamId.get(event.teamId) ?? null,
}));
return {
league,
season,
@ -112,6 +121,7 @@ export async function loader(args: Route.LoaderArgs) {
progressionData,
seasonComplete,
completionPercentage,
recentScoreEvents: enrichedScoreEvents,
};
} catch (error) {
logger.error("Error loading standings:", error);
@ -124,7 +134,20 @@ export async function loader(args: Route.LoaderArgs) {
}
export default function LeagueStandings() {
const { league, season, standings, progressionData, seasonComplete, completionPercentage } = useLoaderData<typeof loader>();
const { league, season, standings, progressionData, seasonComplete, completionPercentage, recentScoreEvents } = useLoaderData<typeof loader>();
const isTied = buildTiedRankChecker(standings.map((s) => s.currentRank));
const previewEntries = standings.map((s) => ({
teamId: s.teamId,
teamName: s.teamName,
ownerName: s.ownerName,
displayRank: getDisplayRank(s, standings.length, isTied(s.currentRank)),
currentRank: s.currentRank,
points: s.totalPoints,
rankChange: s.rankChange,
pointChange: s.sevenDayPointChange,
href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`,
}));
return (
<div className="container mx-auto py-8 px-4">
@ -157,75 +180,26 @@ export default function LeagueStandings() {
</div>
</div>
{/* Standings Card */}
<Card>
<CardHeader>
<CardTitle>
{seasonComplete ? "Final Standings" : "Current Standings"}
</CardTitle>
</CardHeader>
<CardContent>
{standings.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg">No standings data yet.</p>
<p className="text-sm mt-2">
Standings will appear once participants have results.
</p>
</div>
) : (
<StandingsTable
standings={standings}
leagueId={league.id}
seasonId={season.id}
{/* Two-column layout */}
<div className="grid gap-6 md:grid-cols-3">
{/* Left: Standings + Point Progression (2/3 width) */}
<div className="md:col-span-2 space-y-6">
<StandingsPreview
entries={previewEntries}
description={seasonComplete ? "Final Standings" : "Current Standings"}
/>
{progressionData.chartData.length > 0 && (
<PointProgressionChart
chartData={progressionData.chartData}
teams={progressionData.teams}
/>
)}
</CardContent>
</Card>
{/* Point Progression Chart */}
{progressionData.chartData.length > 0 && (
<div className="mt-6">
<PointProgressionChart
chartData={progressionData.chartData}
teams={progressionData.teams}
/>
</div>
)}
{/* Info Card */}
<Card className="mt-6">
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground space-y-2">
<p>
<strong>Projected Points:</strong> Shows actual points from finished
participants plus expected value (EV) from remaining participants based
on their probability distributions. The "+X.X EV" indicates how many
additional points are expected from unfinished participants.
</p>
<p>
<strong>Tiebreaker Rules:</strong> Teams are ranked by total
points. If tied, the team with more 1st place finishes ranks
higher. If still tied, 2nd place finishes are compared, then 3rd,
and so on through 8th place.
</p>
<p>
<strong>Movement Indicators:</strong> Arrows (/) next to rank show rank changes
compared to 7 days ago.
</p>
<p>
<strong>7-Day Change:</strong> Shows points earned and rank movement
over the past 7 days.
</p>
{progressionData.chartData.length > 0 && (
<p>
<strong>Point Progression Chart:</strong> Visualizes how each team's
total points evolved over time based on {progressionData.chartData.length} day{progressionData.chartData.length !== 1 ? 's' : ''} of snapshot data.
{seasonComplete && " Use this to see how the final standings developed throughout the season."}
</p>
)}
</div>
</CardContent>
</Card>
{/* Right: Recent Scores (1/3 width) */}
<RecentScoresCard events={recentScoreEvents} />
</div>
</div>
);
}

View file

@ -1,22 +1,19 @@
import { useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { format } from "date-fns";
import type { Route } from "./+types/$leagueId";
import { Link2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { TeamNameDisplay } from "~/components/ui/team-name-display";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
import { TeamsPanel } from "~/components/league/TeamsPanel";
import { DraftInfoCard } from "~/components/league/DraftInfoCard";
import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { formatAuditDetail } from "~/lib/audit-log-display";
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
@ -36,7 +33,6 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
commissionerMap,
availableTeamCount,
sportsCount,
teamsWithOwners,
isDraftOrderSet,
draftSlots,
sportsSeasons,
@ -47,6 +43,10 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
} = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId);
// 1-based pick position; undefined if myTeam isn't in the slots (order not yet set, or no team)
const userDraftPosition = myTeam
? draftSlots.findIndex((s) => s.team.id === myTeam.id) + 1 || undefined
: undefined;
const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false);
@ -97,6 +97,21 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
return rankA - rankB;
});
const standingsEntries: StandingsPreviewEntry[] = sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
return {
teamId: team.id,
teamName: team.name,
ownerName: team.ownerId ? ownerMap[team.ownerId] : null,
displayRank: getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false),
currentRank: standing?.currentRank,
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
rankChange: standing?.rankChange,
pointChange: standing?.sevenDayPointChange,
};
});
// Pre-compute sorted sports seasons: active → upcoming → completed,
// season_standings last within active, then alphabetical by sport name
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
@ -117,18 +132,28 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-4xl font-bold">{league.name}</h1>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div>
<h1 className="text-4xl font-bold mb-2">{league.name}</h1>
{season && (
<p className="text-muted-foreground">
{season.year} Season {" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</p>
)}
</div>
<div className="flex gap-2 shrink-0">
{myTeam && (
<Button variant="outline" asChild>
<Button variant="outline" size="sm" asChild>
<Link to={`/teams/${myTeam.id}/settings`}>
Team Settings
</Link>
</Button>
)}
{isUserCommissioner && (
<Button variant="outline" asChild>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/settings`}>
League Settings
</Link>
@ -136,14 +161,6 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
)}
</div>
</div>
{season && (
<p className="text-muted-foreground">
{season.year} Season {" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</p>
)}
</div>
{season?.status === "draft" && (
@ -169,94 +186,68 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
<div className="grid gap-6 md:grid-cols-3">
{/* Left Column - 2/3 width on desktop */}
<div className="md:col-span-2 space-y-6">
{/* Draft Info card - pre_draft/draft only */}
{season && isDraftOrPreDraft && (
<DraftInfoCard
draftRounds={season.draftRounds}
draftTimerMode={season.draftTimerMode}
draftInitialTime={season.draftInitialTime}
draftIncrementTime={season.draftIncrementTime}
sportsCount={sportsCount}
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={isDraftOrPreDraft}
draftDateTime={season.draftDateTime}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
userDraftPosition={userDraftPosition}
/>
)}
{/* Standings Panel - active/completed seasons */}
{season && isActiveOrCompleted && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Standings</CardTitle>
<CardDescription>
{season.status === "completed" ? "Final standings" : "Current season standings"}
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/standings/${season.id}`}>
Full Standings
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
{sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div
key={team.id}
className="flex items-center gap-3 py-2 border-b last:border-0"
>
<div className="w-8 text-center text-sm font-bold text-muted-foreground">
{getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false)}
</div>
<div className="flex-1 min-w-0">
<TeamNameDisplay
teamName={team.name}
ownerName={ownerName}
href={`/leagues/${league.id}/standings/${season.id}/teams/${team.id}`}
/>
</div>
<div className="text-sm font-medium tabular-nums">
{standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
<StandingsPreview
entries={standingsEntries}
description={season.status === "completed" ? "Final standings" : undefined}
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
/>
)}
{/* Sports Seasons Section */}
{season && sortedSportsSeasons.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardDescription>
{sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{sortedSportsSeasons.map((sportSeason) => (
<SportSeasonCard
key={sportSeason.id}
leagueId={league.id}
sportSeasonId={sportSeason.id}
sportName={sportSeason.sport.name}
seasonName={sportSeason.name}
status={sportSeason.status}
scoringPattern={sportSeason.scoringPattern}
upcomingParticipantEvents={sportSeason.upcomingParticipantEvents}
/>
))}
</div>
</CardContent>
</Card>
<SportsSeasonsSummary
leagueId={league.id}
sportsSeasons={sortedSportsSeasons.map((ss) => ({
id: ss.id,
sportName: ss.sport.name,
seasonName: ss.name,
status: ss.status,
scoringPattern: ss.scoringPattern,
upcomingParticipantEvents: ss.upcomingParticipantEvents,
draftedParticipants: ss.draftedParticipantsWithPoints,
}))}
/>
)}
{isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && (
<Card>
<CardHeader>
<CardTitle>Invite Link</CardTitle>
<CardDescription>
Share this link to invite people to join your league (
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""}{" "}
available)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-14">
{/* Teams - shown above invite link when league is full */}
{season && isDraftOrPreDraft && availableTeamCount === 0 && (
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
)}
{/* Invite Link - bare, pre_draft commissioner only */}
{isUserCommissioner && season?.status === "pre_draft" && availableTeamCount > 0 && (
<div>
<div className="flex items-center gap-2 mb-4">
<GradientIcon icon={Link2} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Invite Link</span>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""} remaining
</p>
<div className="flex items-center gap-2">
<input
type="text"
@ -276,295 +267,49 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
</CardContent>
</Card>
</div>
</div>
)}
{/* Draft Order card - shown when order is set, pre_draft/draft */}
{season && isDraftOrPreDraft && draftSlots.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{season.status === "pre_draft"
? "Upcoming draft order"
: "Current draft order"}
</CardDescription>
</div>
<Button asChild size="sm">
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
Enter Draft Room
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{draftSlots.map((slot, index) => {
const ownerName = slot.team.ownerId
? ownerMap[slot.team.ownerId]
: null;
return (
<div
key={slot.id}
className="flex items-center gap-3 py-2 border-b last:border-0"
>
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground font-bold text-xs">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{slot.team.name}
</p>
{ownerName && (
<p className="text-xs text-muted-foreground truncate">
{ownerName}
</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Teams - shown below invite link when league is not full */}
{season && isDraftOrPreDraft && availableTeamCount > 0 && (
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
)}
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
{season && isDraftOrPreDraft && (
<Card>
<CardHeader>
<CardTitle>Teams</CardTitle>
<CardDescription>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{teams.map((team) => {
const isOwned = team.ownerId === currentUserId;
const ownerName = team.ownerId
? ownerMap[team.ownerId]
: null;
return (
<Card
key={team.id}
className={isOwned ? "border-primary" : ""}
>
<CardHeader>
<CardTitle className="text-lg">{team.name}</CardTitle>
<CardDescription>
{team.ownerId ? (
isOwned ? (
<span className="text-primary font-medium">
Your Team
</span>
) : (
<span>{ownerName || "Unknown Owner"}</span>
)
) : (
<span className="text-muted-foreground">
Available
</span>
)}
</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-6">
{upcomingCalendarEvents.length > 0 && (
<UpcomingCalendarPanel
<UpcomingEventsCard
events={upcomingCalendarEvents}
showLeague={false}
title="Upcoming Events"
limit={6}
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
showLeagueAvatar={false}
/>
)}
<Card>
<CardHeader>
<CardTitle>League Info</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Created</p>
<p className="font-medium">
{new Date(league.createdAt).toLocaleDateString()}
</p>
</div>
{season && (
<>
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Teams Filled
</p>
<p className="font-medium">
{teamsWithOwners} of {teams.length}
</p>
</div>
)}
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Draft Order Set
</p>
<div className="flex items-center gap-2">
<p className="font-medium">
{isDraftOrderSet ? "Yes" : "No"}
</p>
{!isDraftOrderSet && isUserCommissioner && (
<Button size="sm" variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings#draft-order`}>
Set Order
</Link>
</Button>
)}
</div>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">
Draft Rounds
</p>
<p className="font-medium">{season.draftRounds}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Draft Timer Mode
</p>
<p className="font-medium">
{season.draftTimerMode === "standard" ? "Standard Timer" : "Chess Clock"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Sports Selected
</p>
<p className="font-medium">{sportsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Draft Board</p>
<Link
to={`/leagues/${league.id}/draft-board/${season.id}`}
className="text-electric hover:underline font-medium"
>
View Draft Board
</Link>
</div>
{isActiveOrCompleted && (
<div>
<p className="text-sm text-muted-foreground">Standings</p>
<Link
to={`/leagues/${league.id}/standings/${season.id}`}
className="text-electric hover:underline font-medium"
>
View Standings
</Link>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">Flex Spots</p>
<p className="font-medium text-primary">
{Math.max(0, season.draftRounds - sportsCount)}
</p>
<p className="text-xs text-muted-foreground">
Picks not tied to a specific sport
</p>
</div>
{season.draftDateTime && (
<div>
<p className="text-sm text-muted-foreground">
Draft Date
</p>
<p className="font-medium">
{format(new Date(season.draftDateTime), "PPP 'at' p")}
</p>
</div>
)}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Commissioners</CardTitle>
<CardDescription>
{commissioners.length} commissioner
{commissioners.length !== 1 ? "s" : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{commissioners.map((commissioner) => {
const commissionerName = commissionerMap[commissioner.userId];
const hasTeam = teams.some(
(t) => t.ownerId === commissioner.userId
);
return (
<div
key={commissioner.id}
className="py-2 border-b last:border-0"
>
<p className="font-medium">
{commissionerName || "Unknown User"}
{commissioner.userId === currentUserId && (
<span className="ml-2 text-xs text-primary">
(You)
</span>
)}
</p>
{!hasTeam && (
<p className="text-xs text-muted-foreground">
No team
</p>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{recentActivity.entries.length > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Recent commissioner actions</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{recentActivity.entries.map((entry) => (
<li key={entry.id} className="flex items-start gap-3 text-sm">
<span className="text-muted-foreground whitespace-nowrap shrink-0">
{format(new Date(entry.createdAt), "MMM d, HH:mm")}
</span>
<span>
<span className="font-medium">{entry.actorDisplayName ?? entry.actorClerkId}</span>
{" — "}
<span className="text-muted-foreground">{formatAuditDetail(entry)}</span>
</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Draft historical view - active/completed only */}
{season && isActiveOrCompleted && (
<DraftInfoCard
draftRounds={season.draftRounds}
draftTimerMode={season.draftTimerMode}
draftInitialTime={season.draftInitialTime}
draftIncrementTime={season.draftIncrementTime}
sportsCount={sportsCount}
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={false}
draftDateTime={season.draftDateTime}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
/>
)}
<CommissionersPanel
commissioners={commissioners}
commissionerMap={commissionerMap}
currentUserId={currentUserId}
teams={teams}
activityEntries={recentActivity.entries}
auditLogUrl={`/leagues/${league.id}/audit-log`}
/>
</div>
</div>
</div>

View file

@ -0,0 +1,771 @@
export function meta() {
return [
{ title: "Privacy Policy - Brackt" },
{ name: "description", content: "Privacy Policy for Brackt Fantasy Sports" },
];
}
export default function PrivacyPolicy() {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-4xl font-bold mb-2">Privacy Policy</h1>
<p className="text-muted-foreground mb-10">Last updated April 22, 2026</p>
<div className="space-y-6 text-muted-foreground leading-relaxed">
<p>
This Privacy Notice for Brackt Fantasy Sports (doing business as Brackt) ("we," "us," or "our") describes
how and why we might access, collect, store, use, and/or share ("process") your personal information when
you use our services ("Services"), including when you:
</p>
<ul className="list-disc ml-6 space-y-1">
<li>
Visit our website at{" "}
<a href="https://www.brackt.com" className="text-foreground underline">https://www.brackt.com</a>
{" "}or any website of ours that links to this Privacy Notice
</li>
<li>
Download and use our mobile application (Brackt Fantasy Sports), or any other application of ours that
links to this Privacy Notice
</li>
<li>Engage with us in other related ways, including any marketing or events</li>
</ul>
<p>
<strong className="text-foreground">Questions or concerns?</strong> Reading this Privacy Notice will help
you understand your privacy rights and choices. We are responsible for making decisions about how your
personal information is processed. If you do not agree with our policies and practices, please do not use
our Services. If you still have any questions or concerns, please contact us at{" "}
<a href="mailto:privacy@brackt.com" className="text-foreground underline">privacy@brackt.com</a>.
</p>
{/* Summary */}
<h2 className="text-2xl font-semibold text-foreground pt-4">Summary of Key Points</h2>
<div className="border-b mb-2" />
<p>
<em>
This summary provides key points from our Privacy Notice, but you can find out more details about any of
these topics by using the table of contents below.
</em>
</p>
<p>
<strong className="text-foreground">What personal information do we process?</strong> When you visit, use,
or navigate our Services, we may process personal information depending on how you interact with us and the
Services, the choices you make, and the products and features you use.
</p>
<p>
<strong className="text-foreground">Do we process any sensitive personal information?</strong> We do not
process sensitive personal information.
</p>
<p>
<strong className="text-foreground">Do we collect any information from third parties?</strong> We do not
collect any information from third parties.
</p>
<p>
<strong className="text-foreground">How do we process your information?</strong> We process your
information to provide, improve, and administer our Services, communicate with you, for security and fraud
prevention, and to comply with law. We may also process your information for other purposes with your
consent. We process your information only when we have a valid legal reason to do so.
</p>
<p>
<strong className="text-foreground">In what situations and with which parties do we share personal information?</strong>{" "}
We may share information in specific situations and with specific third parties.
</p>
<p>
<strong className="text-foreground">How do we keep your information safe?</strong> We have adequate
organizational and technical processes and procedures in place to protect your personal information.
However, no electronic transmission over the internet or information storage technology can be guaranteed
to be 100% secure.
</p>
<p>
<strong className="text-foreground">What are your rights?</strong> Depending on where you are located
geographically, the applicable privacy law may mean you have certain rights regarding your personal
information.
</p>
<p>
<strong className="text-foreground">How do you exercise your rights?</strong> The easiest way to exercise
your rights is by visiting{" "}
<a href="https://www.brackt.com/user/data-request" className="text-foreground underline">
https://www.brackt.com/user/data-request
</a>
, or by contacting us. We will consider and act upon any request in accordance with applicable data
protection laws.
</p>
{/* Table of Contents */}
<h2 className="text-2xl font-semibold text-foreground pt-4">Table of Contents</h2>
<div className="border-b mb-2" />
<ol className="list-decimal ml-6 space-y-1">
<li><a href="#infocollect" className="text-foreground underline">What Information Do We Collect?</a></li>
<li><a href="#infouse" className="text-foreground underline">How Do We Process Your Information?</a></li>
<li><a href="#legalbases" className="text-foreground underline">What Legal Bases Do We Rely On to Process Your Personal Information?</a></li>
<li><a href="#whoshare" className="text-foreground underline">When and With Whom Do We Share Your Personal Information?</a></li>
<li><a href="#sociallogins" className="text-foreground underline">How Do We Handle Your Social Logins?</a></li>
<li><a href="#inforetain" className="text-foreground underline">How Long Do We Keep Your Information?</a></li>
<li><a href="#infosafe" className="text-foreground underline">How Do We Keep Your Information Safe?</a></li>
<li><a href="#infominors" className="text-foreground underline">Do We Collect Information from Minors?</a></li>
<li><a href="#privacyrights" className="text-foreground underline">What Are Your Privacy Rights?</a></li>
<li><a href="#DNT" className="text-foreground underline">Controls for Do-Not-Track Features</a></li>
<li><a href="#uslaws" className="text-foreground underline">Do United States Residents Have Specific Privacy Rights?</a></li>
<li><a href="#policyupdates" className="text-foreground underline">Do We Make Updates to This Notice?</a></li>
<li><a href="#contact" className="text-foreground underline">How Can You Contact Us About This Notice?</a></li>
<li><a href="#request" className="text-foreground underline">How Can You Review, Update, or Delete the Data We Collect from You?</a></li>
</ol>
{/* Section 1 */}
<h2 id="infocollect" className="text-2xl font-semibold text-foreground pt-4">
1. What Information Do We Collect?
</h2>
<div className="border-b mb-2" />
<h3 className="text-lg font-semibold text-foreground">Personal information you disclose to us</h3>
<p>
<em><strong>In Short:</strong> We collect personal information that you provide to us.</em>
</p>
<p>
We collect personal information that you voluntarily provide to us when you register on the Services,
express an interest in obtaining information about us or our products and Services, when you participate in
activities on the Services, or otherwise when you contact us.
</p>
<p>
<strong className="text-foreground">Personal Information Provided by You.</strong> The personal
information that we collect depends on the context of your interactions with us and the Services, the
choices you make, and the products and features you use. The personal information we collect may include
the following:
</p>
<ul className="list-disc ml-6 space-y-1">
<li>Email addresses</li>
<li>Usernames</li>
<li>Passwords</li>
</ul>
<p>
<strong className="text-foreground">Sensitive Information.</strong> We do not process sensitive information.
</p>
<p>
<strong className="text-foreground">Social Media Login Data.</strong> We may provide you with the option
to register with us using your existing social media account details, like your Facebook, X, or other
social media account. If you choose to register in this way, we will collect certain profile information
about you from the social media provider, as described in the section "How Do We Handle Your Social
Logins?" below.
</p>
<p>
<strong className="text-foreground">Application Data.</strong> If you use our application(s), we also may
collect the following information if you choose to provide us with access or permission:
</p>
<ul className="list-disc ml-6 space-y-1">
<li>
<em>Push Notifications.</em> We may request to send you push notifications regarding your account or
certain features of the application(s). If you wish to opt out from receiving these types of
communications, you may turn them off in your device's settings.
</li>
</ul>
<p>
This information is primarily needed to maintain the security and operation of our application(s), for
troubleshooting, and for our internal analytics and reporting purposes.
</p>
<p>
All personal information that you provide to us must be true, complete, and accurate, and you must notify
us of any changes to such personal information.
</p>
<h3 className="text-lg font-semibold text-foreground">Information automatically collected</h3>
<p>
<em>
<strong>In Short:</strong> Some information such as your Internet Protocol (IP) address and/or browser
and device characteristics is collected automatically when you visit our Services.
</em>
</p>
<p>
We automatically collect certain information when you visit, use, or navigate the Services. This
information does not reveal your specific identity (like your name or contact information) but may include
device and usage information, such as your IP address, browser and device characteristics, operating
system, language preferences, referring URLs, device name, country, location, information about how and
when you use our Services, and other technical information. This information is primarily needed to
maintain the security and operation of our Services, and for our internal analytics and reporting purposes.
</p>
<p>The information we collect includes:</p>
<ul className="list-disc ml-6 space-y-1">
<li>
<em>Log and Usage Data.</em> Log and usage data is service-related, diagnostic, usage, and performance
information our servers automatically collect when you access or use our Services and which we record in
log files. Depending on how you interact with us, this log data may include your IP address, device
information, browser type, and settings and information about your activity in the Services (such as the
date/time stamps associated with your usage, pages and files viewed, searches, and other actions you
take such as which features you use), device event information (such as system activity, error reports
(sometimes called "crash dumps"), and hardware settings).
</li>
</ul>
{/* Section 2 */}
<h2 id="infouse" className="text-2xl font-semibold text-foreground pt-4">
2. How Do We Process Your Information?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> We process your information to provide, improve, and administer our Services,
communicate with you, for security and fraud prevention, and to comply with law. We may also process
your information for other purposes only with your prior explicit consent.
</em>
</p>
<p>
<strong className="text-foreground">
We process your personal information for a variety of reasons, depending on how you interact with our
Services, including:
</strong>
</p>
<ul className="list-disc ml-6 space-y-2">
<li>
<strong className="text-foreground">To facilitate account creation and authentication and otherwise manage user accounts.</strong>{" "}
We may process your information so you can create and log in to your account, as well as keep your
account in working order.
</li>
<li>
<strong className="text-foreground">To request feedback.</strong> We may process your information when
necessary to request feedback and to contact you about your use of our Services.
</li>
<li>
<strong className="text-foreground">To save or protect an individual's vital interest.</strong> We may
process your information when necessary to save or protect an individual's vital interest, such as to
prevent harm.
</li>
</ul>
{/* Section 3 */}
<h2 id="legalbases" className="text-2xl font-semibold text-foreground pt-4">
3. What Legal Bases Do We Rely On to Process Your Information?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> We only process your personal information when we believe it is necessary and
we have a valid legal reason (i.e., legal basis) to do so under applicable law, like with your consent,
to comply with laws, to provide you with services to enter into or fulfill our contractual obligations,
to protect your rights, or to fulfill our legitimate business interests.
</em>
</p>
<p>
<strong className="text-foreground">
<em><u>If you are located in the EU or UK, this section applies to you.</u></em>
</strong>
</p>
<p>
The General Data Protection Regulation (GDPR) and UK GDPR require us to explain the valid legal bases we
rely on in order to process your personal information. As such, we may rely on the following legal bases
to process your personal information:
</p>
<ul className="list-disc ml-6 space-y-2">
<li>
<strong className="text-foreground">Consent.</strong> We may process your information if you have given
us permission (i.e., consent) to use your personal information for a specific purpose. You can withdraw
your consent at any time.
</li>
<li>
<strong className="text-foreground">Legitimate Interests.</strong> We may process your information when
we believe it is reasonably necessary to achieve our legitimate business interests and those interests
do not outweigh your interests and fundamental rights and freedoms. For example, we may process your
personal information to understand how our users use our products and services so we can improve user
experience.
</li>
<li>
<strong className="text-foreground">Legal Obligations.</strong> We may process your information where
we believe it is necessary for compliance with our legal obligations, such as to cooperate with a law
enforcement body or regulatory agency, exercise or defend our legal rights, or disclose your information
as evidence in litigation in which we are involved.
</li>
<li>
<strong className="text-foreground">Vital Interests.</strong> We may process your information where we
believe it is necessary to protect your vital interests or the vital interests of a third party, such as
situations involving potential threats to the safety of any person.
</li>
</ul>
<p>
<strong className="text-foreground">
<em><u>If you are located in Canada, this section applies to you.</u></em>
</strong>
</p>
<p>
We may process your information if you have given us specific permission (i.e., express consent) to use
your personal information for a specific purpose, or in situations where your permission can be inferred
(i.e., implied consent). You can withdraw your consent at any time.
</p>
<p>
In some exceptional cases, we may be legally permitted under applicable law to process your information
without your consent, including, for example:
</p>
<ul className="list-disc ml-6 space-y-1">
<li>If collection is clearly in the interests of an individual and consent cannot be obtained in a timely way</li>
<li>For investigations and fraud detection and prevention</li>
<li>For business transactions provided certain conditions are met</li>
<li>If it is contained in a witness statement and the collection is necessary to assess, process, or settle an insurance claim</li>
<li>For identifying injured, ill, or deceased persons and communicating with next of kin</li>
<li>If we have reasonable grounds to believe an individual has been, is, or may be victim of financial abuse</li>
<li>If it is reasonable to expect collection and use with consent would compromise the availability or the accuracy of the information and the collection is reasonable for purposes related to investigating a breach of an agreement or a contravention of the laws of Canada or a province</li>
<li>If disclosure is required to comply with a subpoena, warrant, court order, or rules of the court relating to the production of records</li>
<li>If it was produced by an individual in the course of their employment, business, or profession and the collection is consistent with the purposes for which the information was produced</li>
<li>If the collection is solely for journalistic, artistic, or literary purposes</li>
<li>If the information is publicly available and is specified by the regulations</li>
<li>We may disclose de-identified information for approved research or statistics projects, subject to ethics oversight and confidentiality commitments</li>
</ul>
{/* Section 4 */}
<h2 id="whoshare" className="text-2xl font-semibold text-foreground pt-4">
4. When and With Whom Do We Share Your Personal Information?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> We may share information in specific situations described in this section
and/or with the following third parties.
</em>
</p>
<p>We may need to share your personal information in the following situations:</p>
<ul className="list-disc ml-6 space-y-1">
<li>
<strong className="text-foreground">Business Transfers.</strong> We may share or transfer your
information in connection with, or during negotiations of, any merger, sale of company assets,
financing, or acquisition of all or a portion of our business to another company.
</li>
</ul>
{/* Section 5 */}
<h2 id="sociallogins" className="text-2xl font-semibold text-foreground pt-4">
5. How Do We Handle Your Social Logins?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> If you choose to register or log in to our Services using a social media
account, we may have access to certain information about you.
</em>
</p>
<p>
Our Services offer you the ability to register and log in using your third-party social media account
details (like your Facebook or X logins). Where you choose to do this, we will receive certain profile
information about you from your social media provider. The profile information we receive may vary
depending on the social media provider concerned, but will often include your name, email address, friends
list, and profile picture, as well as other information you choose to make public on such a social media
platform.
</p>
<p>
We will use the information we receive only for the purposes that are described in this Privacy Notice or
that are otherwise made clear to you on the relevant Services. Please note that we do not control, and are
not responsible for, other uses of your personal information by your third-party social media provider. We
recommend that you review their privacy notice to understand how they collect, use, and share your personal
information, and how you can set your privacy preferences on their sites and apps.
</p>
{/* Section 6 */}
<h2 id="inforetain" className="text-2xl font-semibold text-foreground pt-4">
6. How Long Do We Keep Your Information?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> We keep your information for as long as necessary to fulfill the purposes
outlined in this Privacy Notice unless otherwise required by law.
</em>
</p>
<p>
We will only keep your personal information for as long as it is necessary for the purposes set out in
this Privacy Notice, unless a longer retention period is required or permitted by law (such as tax,
accounting, or other legal requirements). No purpose in this notice will require us keeping your personal
information for longer than the period of time in which users have an account with us.
</p>
<p>
When we have no ongoing legitimate business need to process your personal information, we will either
delete or anonymize such information, or, if this is not possible (for example, because your personal
information has been stored in backup archives), then we will securely store your personal information and
isolate it from any further processing until deletion is possible.
</p>
{/* Section 7 */}
<h2 id="infosafe" className="text-2xl font-semibold text-foreground pt-4">
7. How Do We Keep Your Information Safe?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> We aim to protect your personal information through a system of
organizational and technical security measures.
</em>
</p>
<p>
We have implemented appropriate and reasonable technical and organizational security measures designed to
protect the security of any personal information we process. However, despite our safeguards and efforts
to secure your information, no electronic transmission over the Internet or information storage technology
can be guaranteed to be 100% secure, so we cannot promise or guarantee that hackers, cybercriminals, or
other unauthorized third parties will not be able to defeat our security and improperly collect, access,
steal, or modify your information. Although we will do our best to protect your personal information,
transmission of personal information to and from our Services is at your own risk. You should only access
the Services within a secure environment.
</p>
{/* Section 8 */}
<h2 id="infominors" className="text-2xl font-semibold text-foreground pt-4">
8. Do We Collect Information from Minors?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> We do not knowingly collect data from or market to children under 18 years
of age or the equivalent age as specified by law in your jurisdiction.
</em>
</p>
<p>
We do not knowingly collect, solicit data from, or market to children under 18 years of age or the
equivalent age as specified by law in your jurisdiction, nor do we knowingly sell such personal
information. By using the Services, you represent that you are at least 18 or the equivalent age as
specified by law in your jurisdiction or that you are the parent or guardian of such a minor and consent
to such minor dependent's use of the Services. If we learn that personal information from users less than
18 years of age has been collected, we will deactivate the account and take reasonable measures to
promptly delete such data from our records. If you become aware of any data we may have collected from
children under age 18, please contact us at{" "}
<a href="mailto:support@brackt.com" className="text-foreground underline">support@brackt.com</a>.
</p>
{/* Section 9 */}
<h2 id="privacyrights" className="text-2xl font-semibold text-foreground pt-4">
9. What Are Your Privacy Rights?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> Depending on your state of residence in the US or in some regions, such as
the European Economic Area (EEA), United Kingdom (UK), Switzerland, and Canada, you have rights that
allow you greater access to and control over your personal information. You may review, change, or
terminate your account at any time, depending on your country, province, or state of residence.
</em>
</p>
<p>
In some regions (like the EEA, UK, Switzerland, and Canada), you have certain rights under applicable
data protection laws. These may include the right (i) to request access and obtain a copy of your
personal information, (ii) to request rectification or erasure; (iii) to restrict the processing of your
personal information; (iv) if applicable, to data portability; and (v) not to be subject to automated
decision-making. In certain circumstances, you may also have the right to object to the processing of your
personal information. You can make such a request by contacting us by using the contact details provided
in the section{" "}
<a href="#contact" className="text-foreground underline">How Can You Contact Us About This Notice?</a>{" "}
below.
</p>
<p>
We will consider and act upon any request in accordance with applicable data protection laws.
</p>
<p>
If you are located in the EEA or UK and you believe we are unlawfully processing your personal
information, you also have the right to complain to your{" "}
<a
href="https://ec.europa.eu/justice/data-protection/bodies/authorities/index_en.htm"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline"
>
Member State data protection authority
</a>{" "}
or{" "}
<a
href="https://ico.org.uk/make-a-complaint/data-protection-complaints/data-protection-complaints/"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline"
>
UK data protection authority
</a>
.
</p>
<p>
If you are located in Switzerland, you may contact the{" "}
<a
href="https://www.edoeb.admin.ch/edoeb/en/home.html"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline"
>
Federal Data Protection and Information Commissioner
</a>
.
</p>
<p>
<strong className="text-foreground"><u>Withdrawing your consent:</u></strong> If we are relying on your
consent to process your personal information, which may be express and/or implied consent depending on the
applicable law, you have the right to withdraw your consent at any time. You can withdraw your consent at
any time by contacting us by using the contact details provided in the section{" "}
<a href="#contact" className="text-foreground underline">How Can You Contact Us About This Notice?</a>{" "}
below or updating your preferences.
</p>
<p>
However, please note that this will not affect the lawfulness of the processing before its withdrawal nor,
when applicable law allows, will it affect the processing of your personal information conducted in
reliance on lawful processing grounds other than consent.
</p>
<p>
<strong className="text-foreground"><u>Opting out of marketing and promotional communications:</u></strong>{" "}
You can unsubscribe from our marketing and promotional communications at any time by clicking on the
unsubscribe link in the emails that we send, or by contacting us using the details provided in the section{" "}
<a href="#contact" className="text-foreground underline">How Can You Contact Us About This Notice?</a>{" "}
below. You will then be removed from the marketing lists. However, we may still communicate with you for
example, to send you service-related messages that are necessary for the administration and use of your
account, to respond to service requests, or for other non-marketing purposes.
</p>
<h3 className="text-lg font-semibold text-foreground">Account Information</h3>
<p>
If you would at any time like to review or change the information in your account or terminate your
account, you can log in to your account settings and update your user account.
</p>
<p>
Upon your request to terminate your account, we will deactivate or delete your account and information
from our active databases. However, we may retain some information in our files to prevent fraud,
troubleshoot problems, assist with any investigations, enforce our legal terms and/or comply with
applicable legal requirements.
</p>
<p>
If you have questions or comments about your privacy rights, you may email us at{" "}
<a href="mailto:privacy@brackt.com" className="text-foreground underline">privacy@brackt.com</a>.
</p>
{/* Section 10 */}
<h2 id="DNT" className="text-2xl font-semibold text-foreground pt-4">
10. Controls for Do-Not-Track Features
</h2>
<div className="border-b mb-2" />
<p>
Most web browsers and some mobile operating systems and mobile applications include a Do-Not-Track ("DNT")
feature or setting you can activate to signal your privacy preference not to have data about your online
browsing activities monitored and collected. At this stage, no uniform technology standard for recognizing
and implementing DNT signals has been finalized. As such, we do not currently respond to DNT browser
signals or any other mechanism that automatically communicates your choice not to be tracked online. If a
standard for online tracking is adopted that we must follow in the future, we will inform you about that
practice in a revised version of this Privacy Notice.
</p>
<p>
California law requires us to let you know how we respond to web browser DNT signals. Because there
currently is not an industry or legal standard for recognizing or honoring DNT signals, we do not respond
to them at this time.
</p>
{/* Section 11 */}
<h2 id="uslaws" className="text-2xl font-semibold text-foreground pt-4">
11. Do United States Residents Have Specific Privacy Rights?
</h2>
<div className="border-b mb-2" />
<p>
<em>
<strong>In Short:</strong> If you are a resident of California, Colorado, Connecticut, Delaware,
Florida, Indiana, Iowa, Kentucky, Maryland, Minnesota, Montana, Nebraska, New Hampshire, New Jersey,
Oregon, Rhode Island, Tennessee, Texas, Utah, or Virginia, you may have the right to request access to
and receive details about the personal information we maintain about you and how we have processed it,
correct inaccuracies, get a copy of, or delete your personal information. You may also have the right
to withdraw your consent to our processing of your personal information. These rights may be limited in
some circumstances by applicable law. More information is provided below.
</em>
</p>
<h3 className="text-lg font-semibold text-foreground">Categories of Personal Information We Collect</h3>
<p>
The table below shows the categories of personal information we have collected in the past twelve (12)
months.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted">
<th className="border border-border p-3 text-left font-semibold text-foreground">Category</th>
<th className="border border-border p-3 text-left font-semibold text-foreground">Examples</th>
<th className="border border-border p-3 text-left font-semibold text-foreground">Collected</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{[
{ cat: "A. Identifiers", ex: "Contact details, such as real name, alias, postal address, telephone or mobile contact number, unique personal identifier, online identifier, Internet Protocol address, email address, and account name", collected: "YES" },
{ cat: "B. Personal information as defined in the California Customer Records statute", ex: "Name, contact information, education, employment, employment history, and financial information", collected: "NO" },
{ cat: "C. Protected classification characteristics under state or federal law", ex: "Gender, age, date of birth, race and ethnicity, national origin, marital status, and other demographic data", collected: "NO" },
{ cat: "D. Commercial information", ex: "Transaction information, purchase history, financial details, and payment information", collected: "NO" },
{ cat: "E. Biometric information", ex: "Fingerprints and voiceprints", collected: "NO" },
{ cat: "F. Internet or other similar network activity", ex: "Browsing history, search history, online behavior, interest data, and interactions with our and other websites, applications, systems, and advertisements", collected: "NO" },
{ cat: "G. Geolocation data", ex: "Device location", collected: "NO" },
{ cat: "H. Audio, electronic, sensory, or similar information", ex: "Images and audio, video or call recordings created in connection with our business activities", collected: "NO" },
{ cat: "I. Professional or employment-related information", ex: "Business contact details in order to provide you our Services at a business level or job title, work history, and professional qualifications if you apply for a job with us", collected: "NO" },
{ cat: "J. Education Information", ex: "Student records and directory information", collected: "NO" },
{ cat: "K. Inferences drawn from collected personal information", ex: "Inferences drawn from any of the collected personal information listed above to create a profile or summary about, for example, an individual's preferences and characteristics", collected: "NO" },
{ cat: "L. Sensitive personal Information", ex: "", collected: "NO" },
].map(({ cat, ex, collected }) => (
<tr key={cat}>
<td className="border border-border p-3">{cat}</td>
<td className="border border-border p-3">{ex}</td>
<td className="border border-border p-3 font-semibold text-foreground">{collected}</td>
</tr>
))}
</tbody>
</table>
</div>
<p>
We may also collect other personal information outside of these categories through instances where you
interact with us in person, online, or by phone or mail in the context of:
</p>
<ul className="list-disc ml-6 space-y-1">
<li>Receiving help through our customer support channels;</li>
<li>Participation in customer surveys or contests; and</li>
<li>Facilitation in the delivery of our Services and to respond to your inquiries.</li>
</ul>
<p>
We will use and retain the collected personal information as needed to provide the Services or for as long
as the user has an account with us.
</p>
<h3 className="text-lg font-semibold text-foreground">Sources of Personal Information</h3>
<p>
Learn more about the sources of personal information we collect in{" "}
<a href="#infocollect" className="text-foreground underline">What Information Do We Collect?</a>
</p>
<h3 className="text-lg font-semibold text-foreground">How We Use and Share Personal Information</h3>
<p>
Learn more about how we use your personal information in the section{" "}
<a href="#infouse" className="text-foreground underline">How Do We Process Your Information?</a>
</p>
<p>
<strong className="text-foreground">Will your information be shared with anyone else?</strong>
</p>
<p>
We may disclose your personal information with our service providers pursuant to a written contract
between us and each service provider. Learn more about how we disclose personal information in the
section{" "}
<a href="#whoshare" className="text-foreground underline">
When and With Whom Do We Share Your Personal Information?
</a>
</p>
<p>
We may use your personal information for our own business purposes, such as for undertaking internal
research for technological development and demonstration. This is not considered to be "selling" of your
personal information.
</p>
<p>
We have not disclosed, sold, or shared any personal information to third parties for a business or
commercial purpose in the preceding twelve (12) months. We will not sell or share personal information in
the future belonging to website visitors, users, and other consumers.
</p>
<h3 className="text-lg font-semibold text-foreground">Your Rights</h3>
<p>
You have rights under certain US state data protection laws. However, these rights are not absolute, and
in certain cases, we may decline your request as permitted by law. These rights include:
</p>
<ul className="list-disc ml-6 space-y-1">
<li><strong className="text-foreground">Right to know</strong> whether or not we are processing your personal data</li>
<li><strong className="text-foreground">Right to access</strong> your personal data</li>
<li><strong className="text-foreground">Right to correct</strong> inaccuracies in your personal data</li>
<li><strong className="text-foreground">Right to request</strong> the deletion of your personal data</li>
<li><strong className="text-foreground">Right to obtain a copy</strong> of the personal data you previously shared with us</li>
<li><strong className="text-foreground">Right to non-discrimination</strong> for exercising your rights</li>
<li><strong className="text-foreground">Right to opt out</strong> of the processing of your personal data if it is used for targeted advertising (or sharing as defined under California's privacy law), the sale of personal data, or profiling in furtherance of decisions that produce legal or similarly significant effects ("profiling")</li>
</ul>
<p>Depending upon the state where you live, you may also have the following rights:</p>
<ul className="list-disc ml-6 space-y-1">
<li>Right to access the categories of personal data being processed (as permitted by applicable law, including the privacy law in Minnesota)</li>
<li>Right to obtain a list of the categories of third parties to which we have disclosed personal data (as permitted by applicable law, including the privacy law in California, Delaware, and Maryland)</li>
<li>Right to obtain a list of specific third parties to which we have disclosed personal data (as permitted by applicable law, including the privacy law in Minnesota and Oregon)</li>
<li>Right to obtain a list of third parties to which we have sold personal data (as permitted by applicable law, including the privacy law in Connecticut)</li>
<li>Right to review, understand, question, and depending on where you live, correct how personal data has been profiled (as permitted by applicable law, including the privacy law in Connecticut and Minnesota)</li>
<li>Right to limit use and disclosure of sensitive personal data (as permitted by applicable law, including the privacy law in California)</li>
<li>Right to opt out of the collection of sensitive data and personal data collected through the operation of a voice or facial recognition feature (as permitted by applicable law, including the privacy law in Florida)</li>
</ul>
<h3 className="text-lg font-semibold text-foreground">How to Exercise Your Rights</h3>
<p>
To exercise these rights, you can contact us by visiting{" "}
<a href="https://www.brackt.com/user/data-request" className="text-foreground underline">
https://www.brackt.com/user/data-request
</a>
, by emailing us at{" "}
<a href="mailto:support@brackt.com" className="text-foreground underline">support@brackt.com</a>
, by visiting{" "}
<a href="https://www.brackt.com/support" className="text-foreground underline">
https://www.brackt.com/support
</a>
, or by referring to the contact details at the bottom of this document.
</p>
<p>
Under certain US state data protection laws, you can designate an authorized agent to make a request on
your behalf. We may deny a request from an authorized agent that does not submit proof that they have been
validly authorized to act on your behalf in accordance with applicable laws.
</p>
<h3 className="text-lg font-semibold text-foreground">Request Verification</h3>
<p>
Upon receiving your request, we will need to verify your identity to determine you are the same person
about whom we have the information in our system. We will only use personal information provided in your
request to verify your identity or authority to make the request. However, if we cannot verify your
identity from the information already maintained by us, we may request that you provide additional
information for the purposes of verifying your identity and for security or fraud-prevention purposes.
</p>
<p>
If you submit the request through an authorized agent, we may need to collect additional information to
verify your identity before processing your request and the agent will need to provide a written and
signed permission from you to submit such request on your behalf.
</p>
<h3 className="text-lg font-semibold text-foreground">Appeals</h3>
<p>
Under certain US state data protection laws, if we decline to take action regarding your request, you may
appeal our decision by emailing us at{" "}
<a href="mailto:privacy@brackt.com" className="text-foreground underline">privacy@brackt.com</a>. We will
inform you in writing of any action taken or not taken in response to the appeal, including a written
explanation of the reasons for the decisions. If your appeal is denied, you may submit a complaint to your
state attorney general.
</p>
<h3 className="text-lg font-semibold text-foreground">California "Shine The Light" Law</h3>
<p>
California Civil Code Section 1798.83, also known as the "Shine The Light" law, permits our users who are
California residents to request and obtain from us, once a year and free of charge, information about
categories of personal information (if any) we disclosed to third parties for direct marketing purposes
and the names and addresses of all third parties with which we shared personal information in the
immediately preceding calendar year. If you are a California resident and would like to make such a
request, please submit your request in writing to us by using the contact details provided in the section{" "}
<a href="#contact" className="text-foreground underline">How Can You Contact Us About This Notice?</a>
</p>
{/* Section 12 */}
<h2 id="policyupdates" className="text-2xl font-semibold text-foreground pt-4">
12. Do We Make Updates to This Notice?
</h2>
<div className="border-b mb-2" />
<p>
<em><strong>In Short:</strong> Yes, we will update this notice as necessary to stay compliant with relevant laws.</em>
</p>
<p>
We may update this Privacy Notice from time to time. The updated version will be indicated by an updated
"Revised" date at the top of this Privacy Notice. If we make material changes to this Privacy Notice, we
may notify you either by prominently posting a notice of such changes or by directly sending you a
notification. We encourage you to review this Privacy Notice frequently to be informed of how we are
protecting your information.
</p>
{/* Section 13 */}
<h2 id="contact" className="text-2xl font-semibold text-foreground pt-4">
13. How Can You Contact Us About This Notice?
</h2>
<div className="border-b mb-2" />
<p>
If you have questions or comments about this notice, you may email us at{" "}
<a href="mailto:support@brackt.com" className="text-foreground underline">support@brackt.com</a>{" "}
or contact us by post at:
</p>
<p className="not-italic">
Brackt Fantasy Sports<br />
United States
</p>
{/* Section 14 */}
<h2 id="request" className="text-2xl font-semibold text-foreground pt-4">
14. How Can You Review, Update, or Delete the Data We Collect from You?
</h2>
<div className="border-b mb-2" />
<p>
Based on the applicable laws of your country or state of residence in the US, you may have the right to
request access to the personal information we collect from you, details about how we have processed it,
correct inaccuracies, or delete your personal information. You may also have the right to withdraw your
consent to our processing of your personal information. These rights may be limited in some circumstances
by applicable law. To request to review, update, or delete your personal information, please visit:{" "}
<a href="https://www.brackt.com/user/data-request" className="text-foreground underline">
https://www.brackt.com/user/data-request
</a>
.
</p>
<p className="text-sm border-t pt-4">
This Privacy Policy was created using Termly's Privacy Policy Generator.
</p>
</div>
</div>
);
}

View file

@ -66,7 +66,8 @@ export default function Rules() {
Points are awarded based on where a drafted team or individual
finishes in their sport. The default point values are listed
below. Commissioners may adjust these values before the draft
begins.
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">
@ -294,59 +295,6 @@ export default function Rules() {
each new round if you pick last in round 1, you pick first in
round 2, and so on.
</p>
<p>
Brackt uses a{" "}
<span className="font-semibold text-foreground">
Fischer increment timer
</span>{" "}
for all drafts. Each participant has a personal time bank. Your
clock counts down while it's your turn, and a fixed amount of
time is added back to your bank each time you make a pick. Time
not used on one pick carries over to future picks.
</p>
<p>
The commissioner selects the draft speed, which determines both
the starting bank and the per-pick increment:
</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">
Speed
</th>
<th className="text-right p-3 font-semibold text-foreground">
Starting Bank
</th>
<th className="text-right p-3 font-semibold text-foreground">
Per-Pick Increment
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">Fast</td>
<td className="p-3 text-right">1 minute</td>
<td className="p-3 text-right">+10 seconds</td>
</tr>
<tr>
<td className="p-3">Standard</td>
<td className="p-3 text-right">2 minutes</td>
<td className="p-3 text-right">+15 seconds</td>
</tr>
<tr>
<td className="p-3">Slow</td>
<td className="p-3 text-right">8 hours</td>
<td className="p-3 text-right">+1 hour</td>
</tr>
<tr>
<td className="p-3">Very Slow</td>
<td className="p-3 text-right">12 hours</td>
<td className="p-3 text-right">+1 hour</td>
</tr>
</tbody>
</table>
</div>
<p>
If a participant's time bank runs out, the system will
automatically draft on their behalf. Autodraft picks from the

View file

@ -1,5 +1,5 @@
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
// Users table - synced from Clerk
export const users = pgTable("users", {
@ -1295,3 +1295,55 @@ export const commissionerAuditLogRelations = relations(commissionerAuditLog, ({
references: [leagues.id],
}),
}));
// ─── Team Score Events ─────────────────────────────────────────────────────────
// Records each time a team's total fantasy points increase as a result of a
// scoring event completing. Used for the "Recent Scores" feed on the standings
// page. One row per team per match (bracket sports) or per event (fallback).
// Participant IDs are stored at write time so attribution is accurate regardless
// of future match results.
export const teamScoreEvents = pgTable("team_score_events", {
id: uuid("id").primaryKey().defaultRandom(),
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
scoringEventId: uuid("scoring_event_id")
.references(() => scoringEvents.id, { onDelete: "set null" }),
scoringEventName: varchar("scoring_event_name", { length: 255 }),
sportName: varchar("sport_name", { length: 255 }),
// Set for bracket sports (one row per match); NULL for event-level fallback rows.
matchId: uuid("match_id").references(() => playoffMatches.id, { onDelete: "set null" }),
// Participant IDs captured at write time — the specific drafted participants
// whose win/advancement caused this score change.
participantIds: text("participant_ids").array(),
pointsDelta: decimal("points_delta", { precision: 10, scale: 2 }).notNull(),
occurredAt: timestamp("occurred_at").defaultNow().notNull(),
}, (t) => ({
// Per-match rows: one row per (team, season, match)
uniqueTeamSeasonMatch: uniqueIndex("team_score_events_match_unique")
.on(t.teamId, t.seasonId, t.matchId)
.where(sql`match_id IS NOT NULL`),
// Event-level fallback rows: one row per (team, season, event) when no match data
uniqueTeamSeasonEvent: uniqueIndex("team_score_events_event_unique")
.on(t.teamId, t.seasonId, t.scoringEventId)
.where(sql`match_id IS NULL`),
}));
export const teamScoreEventsRelations = relations(teamScoreEvents, ({ one }) => ({
team: one(teams, {
fields: [teamScoreEvents.teamId],
references: [teams.id],
}),
season: one(seasons, {
fields: [teamScoreEvents.seasonId],
references: [seasons.id],
}),
scoringEvent: one(scoringEvents, {
fields: [teamScoreEvents.scoringEventId],
references: [scoringEvents.id],
}),
}));

View file

@ -0,0 +1,47 @@
# Architecture
## Server
Dual-server setup:
- **server.ts** — HTTP server entry point, initializes Socket.IO
- **server/app.ts** — Express app with React Router SSR handler and database context
- **server/socket.ts** — Socket.IO server for real-time draft updates
- **server/timer.ts** — Runs every second via `setInterval` to advance draft timers. Uses a dedicated DB connection. **Must be started** for draft functionality to work.
## Database Layer
- **Schema**: `database/schema.ts`
- **Context**: `database/context.ts` — DB connection injected via `AsyncLocalStorage`
- **Models**: `app/models/` — all DB queries live here, organized by domain (e.g. `league.ts`, `season.ts`, `draft-pick.ts`)
Always query through the model layer — never write Drizzle queries directly in route files.
## Frontend
- **Routes config**: `app/routes.ts` — all routes must be explicitly registered here (React Router 7 does not auto-discover files)
- **Route files**: `app/routes/` — file naming uses `.` as path separator; `$param` for dynamic segments; `_index.tsx` for index routes
- **Components**: `app/components/` — UI primitives in `app/components/ui/` (ShadCN)
- **Hooks**: `app/hooks/` — custom React hooks
- **Lib**: `app/lib/` — pure utility functions
## Real-time (Socket.IO)
Clients join a room per season. Key events:
| Event | Direction | Trigger |
|---|---|---|
| `join-draft` | client → server | on draft page load |
| `timer-update` | server → client | every second during active draft |
| `pick-made` | server → client | participant drafted |
| `autodraft-updated` | server → client | team enables/disables autodraft |
| `team-connected` / `team-disconnected` | server → client | presence |
Server emits via `getSocketIO()` from `server/socket.ts`. Clients consume via `useDraftSocket()` hook at `app/hooks/useDraftSocket.ts`.
### Adding a Socket Event
1. Define types in `server/socket.d.ts`
2. Add handler in `server/socket.ts`
3. Emit from server: `getSocketIO().to(room).emit(...)`
4. Listen in client component or hook

7
docs/agents/auth.md Normal file
View file

@ -0,0 +1,7 @@
# Authentication
- Auth is handled by Clerk (`@clerk/react-router`)
- User records are synced from Clerk via webhook at `routes/api/webhooks/clerk.ts`
- Ownership validation: check `ownerId` field against the Clerk user ID (`clerkId`)
- Admin access: `isAdmin` boolean on the users table; all `/admin` routes enforce this
- League commissioners: stored separately in the `commissioners` table (not via `isAdmin`)

35
docs/agents/database.md Normal file
View file

@ -0,0 +1,35 @@
# Database
## Schema Changes Workflow
1. Edit `database/schema.ts`
2. `npm run db:generate` — generates migration SQL + snapshot
3. Verify the SQL in `drizzle/` looks correct
4. Verify a snapshot was created in `drizzle/meta/` (e.g. `0070_snapshot.json`)
5. `npm run db:migrate` — applies migration
6. Add model functions in `app/models/` for new queries
## Drizzle Migration Rules
**NEVER** manually create migration SQL files or edit `drizzle/meta/_journal.json`. The journal and snapshots must stay in sync — drizzle-kit manages both. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed".
- Always use `drizzle-kit generate` (or `drizzle-kit generate --custom` for interactive rename prompts)
- Snapshot JSON must only contain PostgreSQL-valid fields — do not add `"autoincrement"` (Postgres uses sequences, and invalid fields cause Zod validation failures)
- After generating, run `drizzle-kit generate` again to confirm "No schema changes" — validates the snapshot chain
- For destructive migrations (drop/rename), use `IF EXISTS` / `IF NOT EXISTS` guards
## Scoring Event Dates (Non-Obvious Gotcha)
Event dates are stored in **two different columns** depending on event type. Any date-range query must account for both:
1. **`scoringEvents.eventDate`** (`date` column, `YYYY-MM-DD`) — used for non-bracket events (majors, final standings) and sometimes bracket events when the admin sets one date for an entire round.
2. **`playoffMatchGames.scheduledAt`** (`timestamp`) — used for individual games within a bracket match. Bracket events often have `eventDate = null` on the scoring event itself.
**Pattern for date-range queries:** Two-step approach:
1. `selectDistinct` event IDs via left joins through `playoffMatches → playoffMatchGames`, filtering: `eventDate IN [dates] OR (scheduledAt >= dayStart AND scheduledAt <= dayEnd)`
2. `findMany` on the matched IDs to load full event data with relations
All timestamp bounds use UTC (`T00:00:00.000Z` / `T23:59:59.999Z`).
Reference implementation: `getEventsForDates` and `getUpcomingEventsForDraftedParticipants` in `app/models/scoring-event.ts`.

View file

@ -0,0 +1,34 @@
# Domain Models
## Fantasy Draft System
| Model | Description |
|---|---|
| **League** | Top-level container; has one or more commissioners |
| **Season** | One iteration of a league. Status: `pre_draft``draft``active``completed` |
| **Team** | Belongs to a season; owned by a user (`ownerId` = Clerk user ID) |
| **Draft Slot** | Defines draft order for teams in a season |
| **Draft Pick** | Record of each pick made |
| **Draft Queue** | A team's pre-ordered wish list of participants |
| **Draft Timer** | Active countdown for the team currently on the clock |
| **Autodraft Settings** | Per-team config for automatic picking |
Season status drives UI visibility: draft order shown in `pre_draft`, draft controls active in `draft`, etc.
### Snake Draft Logic
- Draft slots define base order (1, 2, 3…)
- Odd rounds go 1→N; even rounds go N→1
- `currentPickNumber` on the season tracks position
- `server/timer.ts` handles autopicks when timer expires
## Sports Data System
| Model | Description |
|---|---|
| **Sport** | Base sport definition (e.g. NFL, NBA); type is `team` or `individual` |
| **Sports Season** | A specific season of a sport (e.g. "2024 NFL Season") |
| **Participant** | An athlete or team that can be drafted |
| **Season Template** | Reusable configuration for creating league seasons |
| **Season Sport** | Junction: links a fantasy season to the sports it covers |
| **Participant Result** | Final standings/scores for participants at season end |

32
docs/agents/routing.md Normal file
View file

@ -0,0 +1,32 @@
# Routing
## Adding a New Route
Both steps are required — skipping step 1 causes a silent 404.
1. **Register in `app/routes.ts`** (do this first):
```typescript
route("admin/sports-seasons/:id/new-page", "routes/admin.sports-seasons.$id.new-page.tsx")
```
Nested admin routes go inside the `route("admin", ...)` array.
2. **Create the file** in `app/routes/` with the matching name.
3. Export `loader` / `action` as needed. TypeScript types are auto-generated via `react-router typegen`.
## File Naming Conventions
- `.` separates path segments: `leagues.$leagueId.settings.tsx``/leagues/:leagueId/settings`
- `$param` = dynamic segment
- `_index.tsx` = index route for a path
## Admin Routes
All admin routes are nested under `/admin` and require `isAdmin: true` on the user:
| Prefix | Area |
|---|---|
| `admin.sports.*` | Sports management |
| `admin.sports-seasons.*` | Sports seasons |
| `admin.templates.*` | Season templates |
| `admin.data-sync.tsx` | Data sync utilities |

30
docs/agents/testing.md Normal file
View file

@ -0,0 +1,30 @@
# Testing
See `TESTING.md` at the project root for the full guide.
## Required Coverage
Tests are required for all new features. Do not consider a feature complete until tests are written and passing.
| What you're adding | Required test |
|---|---|
| Model function | Unit test in co-located `__tests__/` directory |
| Route loader/action | Unit or integration test for the logic |
| Utility function | Unit test |
| Component with non-trivial logic | Component test (React Testing Library) |
| Critical user flow | Cypress E2E test |
## Test Locations
- **Unit / component tests**: `__tests__/` directories co-located with source files
- **E2E tests**: `cypress/e2e/`
- **Fixtures**: `app/test/fixtures/`
## Commands
```bash
npm run test:run # unit tests, run once
npm test # unit tests, watch mode
npm run test:e2e:headless # Cypress headless
npm run test:all # everything
```

View file

@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS "team_score_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL,
"season_id" uuid NOT NULL,
"scoring_event_id" uuid,
"scoring_event_name" varchar(255),
"sport_name" varchar(255),
"points_delta" numeric(10, 2) NOT NULL,
"occurred_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "team_score_events" ADD CONSTRAINT "team_score_events_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 "team_score_events" ADD CONSTRAINT "team_score_events_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 "team_score_events" ADD CONSTRAINT "team_score_events_scoring_event_id_scoring_events_id_fk" FOREIGN KEY ("scoring_event_id") REFERENCES "public"."scoring_events"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "team_score_events_unique" ON "team_score_events" USING btree ("team_id","season_id","scoring_event_id");

View file

@ -0,0 +1 @@
ALTER TABLE "team_score_events" ADD COLUMN "participant_ids" text[];

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS "team_score_events_unique";--> statement-breakpoint
ALTER TABLE "team_score_events" ADD COLUMN "match_id" uuid;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "team_score_events_match_unique" ON "team_score_events" USING btree ("team_id","season_id","match_id") WHERE match_id IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "team_score_events_event_unique" ON "team_score_events" USING btree ("team_id","season_id","scoring_event_id") WHERE match_id IS NULL;

View file

@ -0,0 +1,5 @@
DO $$ BEGIN
ALTER TABLE "team_score_events" ADD CONSTRAINT "team_score_events_match_id_playoff_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."playoff_matches"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -540,6 +540,34 @@
"when": 1776226761564,
"tag": "0076_heavy_zemo",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1776312815334,
"tag": "0077_lively_wolfpack",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1776314387095,
"tag": "0078_striped_rick_jones",
"breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1776316296907,
"tag": "0079_wide_trauma",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1776318182740,
"tag": "0080_chemical_gorilla_man",
"breakpoints": true
}
]
}

36
package-lock.json generated
View file

@ -29,6 +29,7 @@
"@react-router/express": "^7.7.1",
"@react-router/node": "^7.7.1",
"@sentry/react-router": "^10.43.0",
"@tanstack/react-virtual": "^3.13.24",
"@types/nprogress": "^0.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -37,7 +38,7 @@
"drizzle-orm": "~0.36.3",
"express": "^5.1.0",
"isbot": "^5.1.27",
"lucide-react": "^0.545.0",
"lucide-react": "^1.8.0",
"morgan": "^1.10.0",
"next-themes": "^0.4.6",
"nprogress": "^0.2.0",
@ -6491,6 +6492,33 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/cypress": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz",
@ -12517,9 +12545,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.545.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"

View file

@ -51,6 +51,7 @@
"@react-router/express": "^7.7.1",
"@react-router/node": "^7.7.1",
"@sentry/react-router": "^10.43.0",
"@tanstack/react-virtual": "^3.13.24",
"@types/nprogress": "^0.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -59,7 +60,7 @@
"drizzle-orm": "~0.36.3",
"express": "^5.1.0",
"isbot": "^5.1.27",
"lucide-react": "^0.545.0",
"lucide-react": "^1.8.0",
"morgan": "^1.10.0",
"next-themes": "^0.4.6",
"nprogress": "^0.2.0",

Some files were not shown because too many files have changed in this diff Show more