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:
parent
7fa88fc0ef
commit
9ed0282fd0
110 changed files with 29302 additions and 2472 deletions
|
|
@ -1,9 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
import { withRouter } from 'storybook-addon-remix-react-router';
|
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 '../app/app.css'
|
||||||
|
import { BracktGradients } from '../app/components/ui/BracktGradients';
|
||||||
|
|
||||||
|
const withBracktGradients: Decorator = (Story) => (
|
||||||
|
<>
|
||||||
|
<BracktGradients />
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
decorators: [withRouter], // This wraps all stories in a Router context
|
decorators: [withRouter, withBracktGradients],
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
|
|
@ -21,4 +30,4 @@ const preview: Preview = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
34
AGENTS.md
Normal file
34
AGENTS.md
Normal 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
351
CLAUDE.md
|
|
@ -1,343 +1,34 @@
|
||||||
# CLAUDE.md
|
# 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
|
## Commands
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development (HMR enabled)
|
npm run dev # development with HMR
|
||||||
npm run dev
|
npm run typecheck # TypeScript check
|
||||||
|
npm run db:generate # generate Drizzle migration
|
||||||
# Build
|
npm run db:migrate # apply migrations
|
||||||
npm run build # Build both Remix and server
|
npm run test:run # unit tests (once)
|
||||||
npm run build:remix # Build React Router app only
|
npm run test:e2e:headless # Cypress E2E
|
||||||
npm run build:server # Build Express server only
|
npm run test:all # everything
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
**Tests**: Required for all new features. Do not consider a feature complete until tests pass. See [testing guide](docs/agents/testing.md).
|
||||||
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
|
|
||||||
|
|
||||||
**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
|
## Reference Docs
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
|
||||||
119
app/app.css
119
app/app.css
|
|
@ -4,7 +4,7 @@
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@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";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,42 +51,44 @@
|
||||||
|
|
||||||
html {
|
html {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--navbar-height: 64px;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(0.13 0.015 255);
|
--background: #14171e;
|
||||||
--foreground: oklch(0.95 0.01 255);
|
--foreground: #ffffff;
|
||||||
--card: oklch(0.18 0.02 255);
|
--card: #1c1f26;
|
||||||
--card-foreground: oklch(0.95 0.01 255);
|
--card-foreground: #ffffff;
|
||||||
--popover: oklch(0.18 0.02 255);
|
--popover: #1c1f26;
|
||||||
--popover-foreground: oklch(0.95 0.01 255);
|
--popover-foreground: #ffffff;
|
||||||
--primary: oklch(0.623 0.214 259.815);
|
--primary: #adf661;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: #14171e;
|
||||||
--secondary: oklch(0.22 0.025 255);
|
--secondary: #1c1f26;
|
||||||
--secondary-foreground: oklch(0.95 0.01 255);
|
--secondary-foreground: #ffffff;
|
||||||
--muted: oklch(0.22 0.025 255);
|
--muted: #1c1f26;
|
||||||
--muted-foreground: oklch(0.65 0.02 255);
|
--muted-foreground: rgb(255 255 255 / 55%);
|
||||||
--accent: oklch(0.25 0.03 255);
|
--accent: #2ce1c1;
|
||||||
--accent-foreground: oklch(0.95 0.01 255);
|
--accent-foreground: #14171e;
|
||||||
--destructive: oklch(0.65 0.22 25);
|
--destructive: oklch(0.65 0.22 25);
|
||||||
--border: oklch(1 0 0 / 8%);
|
--border: rgb(255 255 255 / 10%);
|
||||||
--input: oklch(1 0 0 / 12%);
|
--input: rgb(255 255 255 / 12%);
|
||||||
--ring: oklch(0.623 0.214 259.815 / 50%);
|
--ring: #adf661;
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: #adf661;
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: #2ce1c1;
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.646 0.222 41.116);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.18 0.02 255);
|
--sidebar: #1c1f26;
|
||||||
--sidebar-foreground: oklch(0.95 0.01 255);
|
--sidebar-foreground: #ffffff;
|
||||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
--sidebar-primary: #adf661;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: #14171e;
|
||||||
--sidebar-accent: oklch(0.25 0.03 255);
|
--sidebar-accent: #2ce1c1;
|
||||||
--sidebar-accent-foreground: oklch(0.95 0.01 255);
|
--sidebar-accent-foreground: #14171e;
|
||||||
--sidebar-border: oklch(1 0 0 / 8%);
|
--sidebar-border: rgb(255 255 255 / 10%);
|
||||||
--sidebar-ring: oklch(0.623 0.214 259.815 / 50%);
|
--sidebar-ring: #adf661;
|
||||||
--electric: oklch(0.623 0.214 259.815);
|
--electric: #2ce1c1;
|
||||||
--amber-accent: oklch(0.79 0.17 70);
|
--amber-accent: oklch(0.79 0.17 70);
|
||||||
--coral-accent: oklch(0.68 0.19 35);
|
--coral-accent: oklch(0.68 0.19 35);
|
||||||
}
|
}
|
||||||
|
|
@ -98,6 +100,63 @@ html {
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 */
|
/* NProgress theme override */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Check, Info } from "lucide-react";
|
import { Check, Info, ChevronDown } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||||
|
|
@ -90,6 +90,221 @@ export function getAutodraftLabel(
|
||||||
return OPTIONS[toAutodraftState(isEnabled, mode, queueOnly)].label;
|
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 {
|
interface AutodraftSettingsProps {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { Card } from "~/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "~/components/ui/context-menu";
|
} 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 {
|
interface DraftCell {
|
||||||
pickNumber: number;
|
pickNumber: number;
|
||||||
|
|
@ -20,6 +22,7 @@ interface DraftGridProps {
|
||||||
team: {
|
team: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
draftGrid: Array<Array<DraftCell | null>>;
|
draftGrid: Array<Array<DraftCell | null>>;
|
||||||
|
|
@ -33,6 +36,9 @@ interface DraftGridProps {
|
||||||
autodraftStatus?: Record<string, boolean>;
|
autodraftStatus?: Record<string, boolean>;
|
||||||
connectedTeams?: Set<string>;
|
connectedTeams?: Set<string>;
|
||||||
ownerMap?: Record<string, string>;
|
ownerMap?: Record<string, string>;
|
||||||
|
coronaStates?: Record<string, CoronaState>;
|
||||||
|
seasonStatus?: SeasonStatus;
|
||||||
|
draftPaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraftGrid({
|
export function DraftGrid({
|
||||||
|
|
@ -48,31 +54,45 @@ export function DraftGrid({
|
||||||
autodraftStatus = {},
|
autodraftStatus = {},
|
||||||
connectedTeams = new Set(),
|
connectedTeams = new Set(),
|
||||||
ownerMap = {},
|
ownerMap = {},
|
||||||
|
coronaStates = {},
|
||||||
|
seasonStatus,
|
||||||
|
draftPaused,
|
||||||
}: DraftGridProps) {
|
}: DraftGridProps) {
|
||||||
const totalTeams = draftSlots.length;
|
const totalTeams = draftSlots.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<div className="p-4">
|
||||||
{title && <h2 className="text-xl font-semibold mb-4">{title}</h2>}
|
{title && <h2 className="text-xl font-semibold mb-4">{title}</h2>}
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<div className="min-w-max">
|
<div className="min-w-max">
|
||||||
{/* Team Headers */}
|
{/* Team Headers */}
|
||||||
<div className="flex gap-2 mb-2">
|
<div className="flex gap-1.5 mb-1.5">
|
||||||
{draftSlots.map((slot) => {
|
{draftSlots.map((slot) => {
|
||||||
const teamTime = teamTimers?.[slot.team.id];
|
const teamTime = teamTimers?.[slot.team.id];
|
||||||
const isAutodraft = autodraftStatus[slot.team.id] || false;
|
const isAutodraft = autodraftStatus[slot.team.id] || false;
|
||||||
const isConnected = connectedTeams.has(slot.team.id);
|
const isConnected = connectedTeams.has(slot.team.id);
|
||||||
|
|
||||||
return (
|
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
|
<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>
|
</div>
|
||||||
{teamTimers && formatTime && (
|
{teamTimers && formatTime && (
|
||||||
<div
|
<div
|
||||||
className={`text-xs font-mono ${
|
className={`text-sm font-mono ${
|
||||||
teamTime === undefined
|
teamTime === undefined
|
||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: teamTime > 60
|
: teamTime > 60
|
||||||
|
|
@ -83,9 +103,6 @@ export function DraftGrid({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatTime(teamTime)}
|
{formatTime(teamTime)}
|
||||||
{isAutodraft && (
|
|
||||||
<span className="ml-1 text-muted-foreground">(auto)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +111,7 @@ export function DraftGrid({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draft Grid Rows */}
|
{/* Draft Grid Rows */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{draftGrid.map((roundPicks, roundIndex) => {
|
{draftGrid.map((roundPicks, roundIndex) => {
|
||||||
const round = roundIndex + 1;
|
const round = roundIndex + 1;
|
||||||
const isEvenRound = round % 2 === 0;
|
const isEvenRound = round % 2 === 0;
|
||||||
|
|
@ -103,48 +120,40 @@ export function DraftGrid({
|
||||||
: roundPicks;
|
: roundPicks;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={round} className="flex gap-2">
|
<div key={round} className="flex gap-1.5">
|
||||||
{displayPicks.map((cell, index) => {
|
{displayPicks.map((cell, index) => {
|
||||||
const actualIndex = isEvenRound
|
const actualIndex = isEvenRound
|
||||||
? roundPicks.length - 1 - index
|
? roundPicks.length - 1 - index
|
||||||
: index;
|
: index;
|
||||||
const slot = draftSlots[actualIndex];
|
|
||||||
const pickNumber = roundIndex * totalTeams + actualIndex + 1;
|
const pickNumber = roundIndex * totalTeams + actualIndex + 1;
|
||||||
const isCurrent = pickNumber === currentPick;
|
const isCurrent = pickNumber === currentPick;
|
||||||
const isPicked = !!cell;
|
const isPicked = !!cell;
|
||||||
|
|
||||||
const cellContent = (
|
const cellContent = (
|
||||||
<div
|
<DraftPickCell
|
||||||
className={`flex-1 min-w-0 h-20 border-2 rounded-lg p-2 transition-all ${
|
key={pickNumber}
|
||||||
isPicked
|
pickNumber={pickNumber}
|
||||||
? "bg-emerald-500/10 border-emerald-500/30"
|
round={round}
|
||||||
: isCurrent
|
pickInRound={actualIndex + 1}
|
||||||
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
|
state={
|
||||||
: "border-border bg-card"
|
isPicked ? "picked" : isCurrent ? "current" : "upcoming"
|
||||||
}`}
|
}
|
||||||
title={`Overall Pick #${pickNumber}`}
|
pick={
|
||||||
>
|
cell
|
||||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
? {
|
||||||
{round}.{String(actualIndex + 1).padStart(2, "0")}
|
participant: { name: cell.participant.name },
|
||||||
</div>
|
sport: { name: cell.sport.name },
|
||||||
{isPicked ? (
|
}
|
||||||
<div className="text-xs">
|
: undefined
|
||||||
<div className="font-semibold truncate">
|
}
|
||||||
{cell.participant.name}
|
coronaState={
|
||||||
</div>
|
cell ? coronaStates[cell.participant.id] : undefined
|
||||||
<div className="text-muted-foreground truncate">
|
}
|
||||||
{cell.sport.name}
|
seasonStatus={seasonStatus}
|
||||||
</div>
|
draftPaused={draftPaused}
|
||||||
</div>
|
/>
|
||||||
) : isCurrent ? (
|
|
||||||
<div className="text-xs font-semibold text-electric">
|
|
||||||
On Clock
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrap with context menu if commissioner and current unpicked cell
|
|
||||||
if (
|
if (
|
||||||
isCommissioner &&
|
isCommissioner &&
|
||||||
!isPicked &&
|
!isPicked &&
|
||||||
|
|
@ -152,6 +161,7 @@ export function DraftGrid({
|
||||||
onForceAutopick &&
|
onForceAutopick &&
|
||||||
onForceManualPick
|
onForceManualPick
|
||||||
) {
|
) {
|
||||||
|
const slot = draftSlots[actualIndex];
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={pickNumber}>
|
<ContextMenu key={pickNumber}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
|
|
@ -185,6 +195,6 @@ export function DraftGrid({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
|
import { useState } from "react";
|
||||||
import type { ReactNode } 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 { Button } from "~/components/ui/button";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "~/components/ui/accordion";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface DraftSidebarProps {
|
interface DraftSidebarProps {
|
||||||
|
|
@ -26,17 +21,19 @@ export function DraftSidebar({
|
||||||
settingsSection,
|
settingsSection,
|
||||||
className,
|
className,
|
||||||
}: DraftSidebarProps) {
|
}: DraftSidebarProps) {
|
||||||
|
const [queueOpen, setQueueOpen] = useState(true);
|
||||||
|
const [picksOpen, setPicksOpen] = useState(true);
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
|
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col h-full",
|
||||||
"w-12 hidden lg:block", // Hide completely on mobile when collapsed
|
"w-12 hidden lg:flex",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Expand button at bottom to match hide button position */}
|
|
||||||
<div className="border-t border-border p-2 flex-shrink-0">
|
<div className="border-t border-border p-2 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -64,46 +61,51 @@ export function DraftSidebar({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
|
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
|
||||||
"w-[450px]",
|
"w-[300px]",
|
||||||
// Mobile: fixed overlay, Desktop: normal sidebar
|
|
||||||
"fixed inset-y-0 left-0 z-50 lg:relative lg:z-auto",
|
"fixed inset-y-0 left-0 z-50 lg:relative lg:z-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Accordion
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
type="multiple"
|
|
||||||
defaultValue={["queue", "recent-picks"]}
|
|
||||||
className="flex-1 overflow-hidden flex flex-col"
|
|
||||||
>
|
|
||||||
{/* Queue Section */}
|
{/* Queue Section */}
|
||||||
<AccordionItem value="queue" className="border-b flex-shrink-0">
|
<div className={cn("flex flex-col border-b", queueOpen ? "flex-1 min-h-0" : "flex-shrink-0")}>
|
||||||
<AccordionTrigger className="px-4 py-3 hover:no-underline bg-muted/50 hover:bg-muted">
|
<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>
|
<h2 className="font-semibold text-sm">My Queue</h2>
|
||||||
</AccordionTrigger>
|
<ChevronDown className={cn("h-4 w-4 transition-transform", queueOpen && "rotate-180")} />
|
||||||
<AccordionContent className="max-h-[35vh] overflow-y-auto pb-0">
|
</button>
|
||||||
{queueSection}
|
{queueOpen && (
|
||||||
</AccordionContent>
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
</AccordionItem>
|
{queueSection}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent Picks Section */}
|
{/* Picks Section */}
|
||||||
<AccordionItem value="recent-picks" className="border-0">
|
<div className={cn("flex flex-col", picksOpen ? "flex-1 min-h-0" : "flex-shrink-0")}>
|
||||||
<AccordionTrigger className="px-4 py-3 hover:no-underline bg-muted/50 hover:bg-muted">
|
<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>
|
<h2 className="font-semibold text-sm">Picks</h2>
|
||||||
</AccordionTrigger>
|
<ChevronDown className={cn("h-4 w-4 transition-transform", picksOpen && "rotate-180")} />
|
||||||
<AccordionContent className="pb-0 overflow-y-auto max-h-[45vh]">
|
</button>
|
||||||
{recentPicksSection}
|
{picksOpen && (
|
||||||
</AccordionContent>
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
</AccordionItem>
|
{recentPicksSection}
|
||||||
</Accordion>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Settings section (e.g. notification toggle) */}
|
|
||||||
{settingsSection && (
|
{settingsSection && (
|
||||||
<div className="border-t border-border px-4 py-3 flex-shrink-0">
|
<div className="border-t border-border px-4 py-3 flex-shrink-0">
|
||||||
{settingsSection}
|
{settingsSection}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collapse button at bottom */}
|
|
||||||
<div className="border-t border-border p-2 flex-shrink-0">
|
<div className="border-t border-border p-2 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface NotificationSettingsProps {
|
||||||
mode: NotificationMode;
|
mode: NotificationMode;
|
||||||
onModeChange: (mode: NotificationMode) => void;
|
onModeChange: (mode: NotificationMode) => void;
|
||||||
permissionState: NotificationPermission | "unsupported";
|
permissionState: NotificationPermission | "unsupported";
|
||||||
|
switchOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationSettings({
|
export function NotificationSettings({
|
||||||
|
|
@ -17,11 +18,46 @@ export function NotificationSettings({
|
||||||
mode,
|
mode,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
permissionState,
|
permissionState,
|
||||||
|
switchOnly = false,
|
||||||
}: NotificationSettingsProps) {
|
}: NotificationSettingsProps) {
|
||||||
if (permissionState === "unsupported") {
|
if (permissionState === "unsupported") {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
61
app/components/TeamAvatar.tsx
Normal file
61
app/components/TeamAvatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,29 @@ import { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
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 = {
|
const defaultProps = {
|
||||||
participants: [
|
participants: [
|
||||||
{ id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } },
|
{ id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } },
|
||||||
|
|
@ -29,6 +52,11 @@ const defaultProps = {
|
||||||
onMakePick: vi.fn(),
|
onMakePick: vi.fn(),
|
||||||
onAddToQueue: vi.fn(),
|
onAddToQueue: vi.fn(),
|
||||||
onRemoveFromQueue: 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
|
// Both the mobile Sheet trigger and desktop Popover trigger are rendered in jsdom
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,45 @@ describe('DraftGrid Component', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Current Pick Highlighting', () => {
|
describe('Current Pick Highlighting', () => {
|
||||||
it('should highlight current pick with "On Clock" text', () => {
|
it('should show "Up Next" when draft is pre_draft', () => {
|
||||||
render(
|
render(
|
||||||
<DraftGrid
|
<DraftGrid
|
||||||
draftSlots={mockDraftSlots}
|
draftSlots={mockDraftSlots}
|
||||||
draftGrid={mockGrid}
|
draftGrid={mockGrid}
|
||||||
currentPick={2}
|
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', () => {
|
it('should apply correct styling to current pick', () => {
|
||||||
|
|
@ -85,12 +114,14 @@ describe('DraftGrid Component', () => {
|
||||||
draftSlots={mockDraftSlots}
|
draftSlots={mockDraftSlots}
|
||||||
draftGrid={mockGrid}
|
draftGrid={mockGrid}
|
||||||
currentPick={2}
|
currentPick={2}
|
||||||
|
seasonStatus="draft"
|
||||||
|
draftPaused={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentPickCell = screen.getByText('On Clock').parentElement;
|
const currentPickCell = screen.getByText('On The Clock').closest('.border-transparent');
|
||||||
expect(currentPickCell).toHaveClass('border-electric');
|
expect(currentPickCell).toHaveClass('bg-muted/80');
|
||||||
expect(currentPickCell).toHaveClass('bg-electric/15');
|
expect(currentPickCell).toHaveClass('border-transparent');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
|
|
@ -130,6 +132,8 @@ interface AvailableParticipantsSectionProps {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
participantRanks: Map<string, { overallRank: number; sportRank: number }>;
|
||||||
|
miniDraftGrid?: MiniDraftGridProps;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
sportFilters: string[];
|
sportFilters: string[];
|
||||||
hideDrafted: boolean;
|
hideDrafted: boolean;
|
||||||
|
|
@ -157,6 +161,8 @@ interface AvailableParticipantsSectionProps {
|
||||||
|
|
||||||
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
|
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
|
||||||
participants,
|
participants,
|
||||||
|
participantRanks,
|
||||||
|
miniDraftGrid,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
sportFilters,
|
sportFilters,
|
||||||
hideDrafted,
|
hideDrafted,
|
||||||
|
|
@ -198,7 +204,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
? "All Sports"
|
? "All Sports"
|
||||||
: sportFilters.length === 1
|
: sportFilters.length === 1
|
||||||
? sportFilters[0]
|
? sportFilters[0]
|
||||||
: `${sportFilters.length} sports`,
|
: `${sportFilters.length} Sports`,
|
||||||
[sportFilters]
|
[sportFilters]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -241,106 +247,119 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
|
|
||||||
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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="px-4 pt-4 pb-2 flex-shrink-0">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center">
|
||||||
{/* Search Input */}
|
<div className="flex gap-2 md:contents">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search participants..."
|
placeholder="Search participants..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
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"
|
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">
|
<div className="md:hidden shrink-0">
|
||||||
{/* Sport Filter Multi-Select */}
|
<Sheet>
|
||||||
<div className="flex-1 min-w-[120px]">
|
<SheetTrigger asChild>
|
||||||
{/* Mobile: bottom sheet */}
|
<Button
|
||||||
<div className="md:hidden">
|
variant="outline"
|
||||||
<Sheet>
|
aria-label={triggerAriaLabel}
|
||||||
<SheetTrigger asChild>
|
className="justify-between text-base font-normal"
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
<span className="truncate">{triggerText}</span>
|
||||||
aria-label={triggerAriaLabel}
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
className="w-full justify-between text-base font-normal"
|
</Button>
|
||||||
>
|
</SheetTrigger>
|
||||||
<span className="truncate">{triggerText}</span>
|
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
|
||||||
</Button>
|
<SportFilterContent
|
||||||
</SheetTrigger>
|
sportsForDropdown={sportsForDropdown}
|
||||||
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
|
sportFilterSet={sportFilterSet}
|
||||||
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
|
hideCompletedSports={hideCompletedSports}
|
||||||
<SportFilterContent
|
hasTeam={hasTeam}
|
||||||
sportsForDropdown={sportsForDropdown}
|
variant="sheet"
|
||||||
sportFilterSet={sportFilterSet}
|
onToggleSport={handleToggleSport}
|
||||||
hideCompletedSports={hideCompletedSports}
|
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
||||||
hasTeam={hasTeam}
|
/>
|
||||||
variant="sheet"
|
<SheetFooter className="px-4 pb-8 flex-row gap-2">
|
||||||
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">
|
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<div className="flex justify-end px-2 pt-2">
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="ghost"
|
className="flex-1"
|
||||||
size="sm"
|
onClick={handleReset}
|
||||||
className="h-auto px-2 py-1 text-xs"
|
>
|
||||||
onClick={handleReset}
|
Reset
|
||||||
>
|
</Button>
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<SportFilterContent
|
<SheetClose asChild>
|
||||||
sportsForDropdown={sportsForDropdown}
|
<Button className="flex-1">Done</Button>
|
||||||
sportFilterSet={sportFilterSet}
|
</SheetClose>
|
||||||
hideCompletedSports={hideCompletedSports}
|
</SheetFooter>
|
||||||
hasTeam={hasTeam}
|
</SheetContent>
|
||||||
variant="popover"
|
</Sheet>
|
||||||
onToggleSport={handleToggleSport}
|
|
||||||
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show/Hide Drafted Toggle */}
|
<div className="hidden md:block shrink-0">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
<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
|
<Checkbox
|
||||||
checked={!hideDrafted}
|
checked={!hideDrafted}
|
||||||
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
|
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
|
||||||
|
|
@ -348,9 +367,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
<span>Show Drafted</span>
|
<span>Show Drafted</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Show/Hide Ineligible Toggle - only show if user has a team */}
|
|
||||||
{hasTeam && eligibility && (
|
{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
|
<Checkbox
|
||||||
checked={!hideIneligible}
|
checked={!hideIneligible}
|
||||||
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
|
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
|
||||||
|
|
@ -359,162 +377,68 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Card List - visible on mobile only */}
|
<div className={`hidden md:grid ${desktopGridClass} bg-muted border-b text-sm font-semibold flex-shrink-0 px-4`}>
|
||||||
<div className="flex md:hidden flex-1 overflow-y-auto flex-col gap-2 px-4 py-2">
|
<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 ? (
|
{participants.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
participants.map((participant) => {
|
<div
|
||||||
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
|
style={{
|
||||||
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={participant.id}
|
key={virtualItem.key}
|
||||||
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
|
data-index={virtualItem.index}
|
||||||
isDrafted
|
ref={virtualizer.measureElement}
|
||||||
? "bg-muted/50 opacity-60"
|
style={{
|
||||||
: !isEligible
|
position: "absolute",
|
||||||
? "bg-destructive/10 opacity-75"
|
top: virtualItem.start,
|
||||||
: ""
|
left: 0,
|
||||||
}`}
|
right: 0,
|
||||||
>
|
}}
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
>
|
||||||
<span
|
<div className="md:hidden px-4 py-1">
|
||||||
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
<div
|
||||||
>
|
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
|
||||||
{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 ${
|
|
||||||
isDrafted
|
isDrafted
|
||||||
? "bg-muted/50 opacity-60"
|
? "bg-muted/50 opacity-60"
|
||||||
: !isEligible
|
: !isEligible
|
||||||
? "bg-destructive/10 opacity-75"
|
? "bg-destructive/10 opacity-75"
|
||||||
: "hover:bg-muted/50"
|
: ""
|
||||||
}`}
|
}`}
|
||||||
title={
|
|
||||||
!isEligible && !isDrafted ? ineligibleReason : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<span
|
||||||
<span
|
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
||||||
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
>
|
||||||
>
|
{participant.name}
|
||||||
{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>
|
</span>
|
||||||
{isDrafted && (
|
{isDrafted && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
|
@ -536,71 +460,162 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="p-3 text-muted-foreground">
|
{hasTeam && !isDrafted && (
|
||||||
{participant.sport.name}
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
</td>
|
{!isInQueue ? (
|
||||||
{hasTeam && (
|
<Button
|
||||||
<td className="p-3 text-right">
|
variant="ghost"
|
||||||
<div className="flex gap-2 justify-end items-center">
|
size="sm"
|
||||||
{!isDrafted && (
|
className="min-h-[44px] min-w-[44px]"
|
||||||
<>
|
onClick={() => onAddToQueue(participant.id)}
|
||||||
{/* Queue icon button */}
|
title={!isEligible ? ineligibleReason : "Add to queue"}
|
||||||
{!isInQueue ? (
|
disabled={!isEligible}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<ListPlus className="h-4 w-4" />
|
||||||
size="sm"
|
</Button>
|
||||||
onClick={() => onAddToQueue(participant.id)}
|
) : (
|
||||||
title={
|
<Button
|
||||||
!isEligible
|
variant="ghost"
|
||||||
? ineligibleReason
|
size="sm"
|
||||||
: "Add to queue"
|
className="min-h-[44px] min-w-[44px]"
|
||||||
}
|
onClick={() => {
|
||||||
disabled={!isEligible}
|
const queueId = queueMap.get(participant.id);
|
||||||
>
|
if (queueId) onRemoveFromQueue(queueId);
|
||||||
<ListPlus className="h-4 w-4" />
|
}}
|
||||||
</Button>
|
title="Remove from queue"
|
||||||
) : (
|
>
|
||||||
<Button
|
<ListX className="h-4 w-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="sm"
|
)}
|
||||||
onClick={() => {
|
<Button
|
||||||
const queueId = queueMap.get(participant.id);
|
variant="default"
|
||||||
if (queueId) onRemoveFromQueue(queueId);
|
size="sm"
|
||||||
}}
|
className="min-h-[44px]"
|
||||||
title="Remove from queue"
|
onClick={() => onMakePick(participant.id)}
|
||||||
>
|
disabled={!canPick || !isEligible}
|
||||||
<ListX className="h-4 w-4" />
|
title={
|
||||||
</Button>
|
!canPick
|
||||||
)}
|
? "Not your turn"
|
||||||
{/* Draft button - always visible, disabled when not your turn */}
|
: !isEligible
|
||||||
<Button
|
? ineligibleReason
|
||||||
variant="default"
|
: undefined
|
||||||
size="sm"
|
}
|
||||||
onClick={() => onMakePick(participant.id)}
|
>
|
||||||
disabled={!canPick || !isEligible}
|
Draft
|
||||||
title={
|
</Button>
|
||||||
!canPick
|
</div>
|
||||||
? "Not your turn"
|
|
||||||
: !isEligible
|
|
||||||
? ineligibleReason
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Draft
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)}
|
)}
|
||||||
</tr>
|
</div>
|
||||||
);
|
</div>
|
||||||
})
|
|
||||||
)}
|
<div
|
||||||
</tbody>
|
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors border-t ${
|
||||||
</table>
|
isDrafted
|
||||||
</div>
|
? "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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { memo, useMemo, useState } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
|
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
|
|
@ -14,6 +15,8 @@ import {
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { MoreVertical } from "lucide-react";
|
import { MoreVertical } from "lucide-react";
|
||||||
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
||||||
|
import { DraftPickCell } from "~/components/draft/DraftPickCell";
|
||||||
|
import type { SeasonStatus } from "~/models/season";
|
||||||
|
|
||||||
type MobileSheetData =
|
type MobileSheetData =
|
||||||
| { type: "team"; teamId: string }
|
| { type: "team"; teamId: string }
|
||||||
|
|
@ -27,6 +30,7 @@ interface DraftGridSectionProps {
|
||||||
team: {
|
team: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
draftGrid: Array<
|
draftGrid: Array<
|
||||||
|
|
@ -50,6 +54,8 @@ interface DraftGridSectionProps {
|
||||||
autodraftStatus: Record<string, { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
|
autodraftStatus: Record<string, { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
|
||||||
connectedTeams: Set<string>;
|
connectedTeams: Set<string>;
|
||||||
isCommissioner: boolean;
|
isCommissioner: boolean;
|
||||||
|
seasonStatus?: SeasonStatus;
|
||||||
|
draftPaused?: boolean;
|
||||||
onAdjustTimeBankOpen?: (teamId: string) => void;
|
onAdjustTimeBankOpen?: (teamId: string) => void;
|
||||||
onSetAutodraftOpen?: (teamId: string) => void;
|
onSetAutodraftOpen?: (teamId: string) => void;
|
||||||
onForceAutopick: (pickNumber: number, teamId: string) => void;
|
onForceAutopick: (pickNumber: number, teamId: string) => void;
|
||||||
|
|
@ -67,6 +73,8 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
autodraftStatus,
|
autodraftStatus,
|
||||||
connectedTeams,
|
connectedTeams,
|
||||||
isCommissioner,
|
isCommissioner,
|
||||||
|
seasonStatus,
|
||||||
|
draftPaused,
|
||||||
onAdjustTimeBankOpen,
|
onAdjustTimeBankOpen,
|
||||||
onSetAutodraftOpen,
|
onSetAutodraftOpen,
|
||||||
onForceAutopick,
|
onForceAutopick,
|
||||||
|
|
@ -88,9 +96,7 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="inline-block min-w-full min-h-full">
|
<div className="inline-block min-w-full min-h-full">
|
||||||
{/* Team Headers */}
|
{/* Team Headers */}
|
||||||
<div className="flex gap-2 mb-2 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-1">
|
<div className="flex gap-1.5 mb-1.5 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" />
|
|
||||||
{draftSlots.map((slot) => {
|
{draftSlots.map((slot) => {
|
||||||
const teamTime = teamTimers[slot.team.id];
|
const teamTime = teamTimers[slot.team.id];
|
||||||
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
|
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
|
||||||
|
|
@ -98,26 +104,32 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
|
|
||||||
const headerInner = (
|
const headerInner = (
|
||||||
<div className="relative">
|
<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
|
<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
|
slot.team.id === currentTeamId
|
||||||
? "text-electric font-bold"
|
? "text-electric font-bold"
|
||||||
: !isConnected
|
: !isConnected
|
||||||
? "italic text-muted-foreground"
|
? "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>
|
||||||
<div
|
<div
|
||||||
className={`text-xs font-mono px-2 ${getTimerColorClass(teamTime)}`}
|
className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}
|
||||||
>
|
>
|
||||||
{formatClockTime(teamTime)}
|
{formatClockTime(teamTime)}
|
||||||
{isAutodraft && (
|
|
||||||
<span className="ml-1 text-muted-foreground">
|
|
||||||
(auto)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
|
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -134,7 +146,7 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={slot.id}>
|
<ContextMenu key={slot.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<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}
|
{headerInner}
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
@ -155,7 +167,7 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{headerInner}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -163,7 +175,7 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draft Grid Rows */}
|
{/* Draft Grid Rows */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{draftGrid.map((roundPicks, roundIndex) => {
|
{draftGrid.map((roundPicks, roundIndex) => {
|
||||||
const round = roundIndex + 1;
|
const round = roundIndex + 1;
|
||||||
const isEvenRound = round % 2 === 0;
|
const isEvenRound = round % 2 === 0;
|
||||||
|
|
@ -172,45 +184,19 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
: roundPicks;
|
: roundPicks;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={round} className="flex gap-2 items-stretch">
|
<div key={round} className="flex gap-1.5 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>
|
|
||||||
{displayPicks.map((cell) => {
|
{displayPicks.map((cell) => {
|
||||||
const isCurrent = cell.pickNumber === currentPick;
|
const isCurrent = cell.pickNumber === currentPick;
|
||||||
const isPicked = !!cell.pick;
|
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 ${
|
const mobileButtons = isCommissioner && (
|
||||||
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 = (
|
|
||||||
<>
|
<>
|
||||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
{!isPicked && isCurrent && (
|
||||||
{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 && (
|
|
||||||
<button
|
<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"
|
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 })}
|
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" />
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isCommissioner && isPicked && (onReplacePick || onRollbackToPick) && (
|
{isPicked && (onReplacePick || onRollbackToPick) && (
|
||||||
<button
|
<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"
|
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 })}
|
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) {
|
if (isCommissioner && !isPicked && isCurrent) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={cell.pickNumber}>
|
<ContextMenu key={cell.pickNumber}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<DraftPickCell
|
||||||
className={cellClassName}
|
pickNumber={cell.pickNumber}
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
round={cell.round}
|
||||||
|
pickInRound={cell.pickInRound}
|
||||||
|
state={cellState}
|
||||||
|
pick={pickData}
|
||||||
|
coronaState={pendingCorona}
|
||||||
|
seasonStatus={seasonStatus}
|
||||||
|
draftPaused={draftPaused}
|
||||||
|
className="cursor-context-menu"
|
||||||
>
|
>
|
||||||
{cellInner}
|
{mobileButtons}
|
||||||
</div>
|
</DraftPickCell>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|
@ -264,17 +256,23 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commissioner context menu on already-picked cells
|
|
||||||
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
|
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={cell.pickNumber}>
|
<ContextMenu key={cell.pickNumber}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<DraftPickCell
|
||||||
className={cellClassName}
|
pickNumber={cell.pickNumber}
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
round={cell.round}
|
||||||
|
pickInRound={cell.pickInRound}
|
||||||
|
state={cellState}
|
||||||
|
pick={pickData}
|
||||||
|
coronaState={pendingCorona}
|
||||||
|
seasonStatus={seasonStatus}
|
||||||
|
draftPaused={draftPaused}
|
||||||
|
className="cursor-context-menu"
|
||||||
>
|
>
|
||||||
{cellInner}
|
{mobileButtons}
|
||||||
</div>
|
</DraftPickCell>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
{onReplacePick && (
|
{onReplacePick && (
|
||||||
|
|
@ -300,13 +298,19 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DraftPickCell
|
||||||
key={cell.pickNumber}
|
key={cell.pickNumber}
|
||||||
className={cellClassName}
|
pickNumber={cell.pickNumber}
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
round={cell.round}
|
||||||
|
pickInRound={cell.pickInRound}
|
||||||
|
state={cellState}
|
||||||
|
pick={pickData}
|
||||||
|
coronaState={pendingCorona}
|
||||||
|
seasonStatus={seasonStatus}
|
||||||
|
draftPaused={draftPaused}
|
||||||
>
|
>
|
||||||
{cellInner}
|
{mobileButtons}
|
||||||
</div>
|
</DraftPickCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
240
app/components/draft/DraftPickCell.stories.tsx
Normal file
240
app/components/draft/DraftPickCell.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
148
app/components/draft/DraftPickCell.tsx
Normal file
148
app/components/draft/DraftPickCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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";
|
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
||||||
|
|
||||||
interface DraftSummaryViewProps {
|
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>;
|
ownerMap?: Record<string, string>;
|
||||||
picks: DraftPick[];
|
picks: DraftPick[];
|
||||||
sports: Array<{ id: string; name: string }>;
|
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 (
|
return (
|
||||||
<div className="w-full h-full overflow-auto">
|
<div className="w-full h-full overflow-auto">
|
||||||
<table className="w-full border-collapse">
|
<div className="grid w-full" style={{ gridTemplateColumns: gridCols }}>
|
||||||
<thead className="sticky top-0 bg-background z-10">
|
|
||||||
<tr>
|
{/* Header row */}
|
||||||
<th
|
<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">
|
||||||
scope="col"
|
Sport
|
||||||
className="border-r border-b border-border p-2 text-left font-semibold bg-muted sticky left-0 z-20 min-w-[120px]"
|
</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
|
<div className="flex flex-col items-center gap-1">
|
||||||
</th>
|
<TeamAvatar
|
||||||
<th
|
teamId={slot.team.id}
|
||||||
scope="col"
|
teamName={ownerName || slot.team.name}
|
||||||
className="border-r border-b border-border p-2 text-center font-semibold bg-muted min-w-[48px]"
|
logoUrl={slot.team.logoUrl}
|
||||||
>
|
size="md"
|
||||||
Total
|
/>
|
||||||
</th>
|
<div className="text-xs font-semibold text-center truncate w-full">
|
||||||
{draftSlots.map((slot, index) => {
|
{ownerName || slot.team.name}
|
||||||
const displayName = ownerMap[slot.team.id] || slot.team.name;
|
</div>
|
||||||
const isLast = index === draftSlots.length - 1;
|
{flexPicksAvailable > 0 && (
|
||||||
const used = flexPicksUsedByTeam.get(slot.team.id) ?? 0;
|
<span className={`text-xs ${flexesExhausted ? "text-destructive font-medium" : "text-muted-foreground"}`}>
|
||||||
const flexesExhausted = flexPicksAvailable > 0 && used >= flexPicksAvailable;
|
{used}/{flexPicksAvailable} flex picks
|
||||||
return (
|
</span>
|
||||||
<th
|
)}
|
||||||
key={slot.id}
|
</div>
|
||||||
scope="col"
|
</div>
|
||||||
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 && (
|
{/* Data rows */}
|
||||||
<div className={`text-xs mt-0.5 ${flexesExhausted ? "text-destructive font-medium" : "font-normal text-muted-foreground"}`}>
|
{sports.map((sport, sportIndex) => {
|
||||||
{used} of {flexPicksAvailable} flexes
|
const isLastRow = sportIndex === sports.length - 1;
|
||||||
</div>
|
const { total: sportTotal, teams: sportTeamCount } =
|
||||||
)}
|
sportStats.get(sport.id) ?? { total: 0, teams: 0 };
|
||||||
</th>
|
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" : ""}`}>
|
||||||
</tr>
|
{sport.name}
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div className={`border-r border-border px-2 py-2.5 text-center bg-muted/20 ${!isLastRow ? "border-b" : ""}`}>
|
||||||
{sports.map((sport, sportIndex) => {
|
{sportTotal > 0 ? (
|
||||||
const isLastRow = sportIndex === sports.length - 1;
|
<>
|
||||||
const { total: sportTotal, teams: sportTeamCount } = sportStats.get(sport.id) ?? { total: 0, teams: 0 };
|
<div className="text-sm font-bold tabular-nums">{sportTotal}</div>
|
||||||
return (
|
<div className="text-xs text-muted-foreground">{sportTeamCount}/{draftSlots.length} teams</div>
|
||||||
<tr key={sport.id}>
|
</>
|
||||||
<th
|
) : (
|
||||||
scope="row"
|
<span className="text-muted-foreground/40 text-sm">—</span>
|
||||||
className={`border-r border-border p-2 font-medium text-sm text-left bg-muted sticky left-0 z-10 ${!isLastRow ? "border-b" : ""}`}
|
)}
|
||||||
>
|
</div>
|
||||||
{sport.name}
|
{draftSlots.map((slot, slotIndex) => {
|
||||||
</th>
|
const count = picksByTeamAndSport.get(slot.team.id)?.get(sport.id)?.length ?? 0;
|
||||||
<td
|
const isLast = slotIndex === draftSlots.length - 1;
|
||||||
className={`border-r border-border p-2 text-center text-sm font-semibold bg-muted/30 ${!isLastRow ? "border-b" : ""}`}
|
const isFlex = count >= 2;
|
||||||
>
|
return (
|
||||||
{sportTotal > 0 ? (
|
<div
|
||||||
<>
|
key={`${slot.team.id}-${sport.id}`}
|
||||||
<div>{sportTotal}</div>
|
className={`border-border px-3 py-2.5 text-center ${isFlex ? "bg-amber-500/10" : "bg-background"} ${!isLastRow ? "border-b" : ""} ${!isLast ? "border-r" : ""}`}
|
||||||
<div className="text-xs font-normal text-muted-foreground mt-0.5">
|
>
|
||||||
{sportTeamCount} team{sportTeamCount !== 1 ? "s" : ""}
|
{count > 0 ? (
|
||||||
</div>
|
<span className={`text-sm font-bold tabular-nums ${isFlex ? "text-amber-600 dark:text-amber-400" : ""}`}>
|
||||||
</>
|
{count}
|
||||||
) : null}
|
</span>
|
||||||
</td>
|
) : (
|
||||||
{draftSlots.map((slot, slotIndex) => {
|
<span className="text-muted-foreground/30 text-sm">—</span>
|
||||||
const count =
|
)}
|
||||||
picksByTeamAndSport.get(slot.team.id)?.get(sport.id)
|
</div>
|
||||||
?.length ?? 0;
|
);
|
||||||
const isLast = slotIndex === draftSlots.length - 1;
|
})}
|
||||||
return (
|
</Fragment>
|
||||||
<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"
|
</div>
|
||||||
} ${!isLastRow ? "border-b" : ""} ${!isLast ? "border-r" : ""}`}
|
|
||||||
>
|
|
||||||
{count > 0 ? count : null}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
145
app/components/draft/MiniDraftGrid.stories.tsx
Normal file
145
app/components/draft/MiniDraftGrid.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
289
app/components/draft/MiniDraftGrid.tsx
Normal file
289
app/components/draft/MiniDraftGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
122
app/components/draft/QueueSection.stories.tsx
Normal file
122
app/components/draft/QueueSection.stories.tsx
Normal 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: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,6 @@ import { memo, useCallback, useMemo } from "react";
|
||||||
import { GripVertical } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
|
@ -31,17 +30,8 @@ interface QueueSectionProps {
|
||||||
name: string;
|
name: string;
|
||||||
sport: { name: string };
|
sport: { name: string };
|
||||||
}>;
|
}>;
|
||||||
seasonId: string;
|
|
||||||
teamId: string;
|
|
||||||
isMyTurn: boolean;
|
|
||||||
canPick: boolean;
|
canPick: boolean;
|
||||||
userAutodraft: {
|
|
||||||
isEnabled: boolean;
|
|
||||||
mode: "next_pick" | "while_on";
|
|
||||||
queueOnly: boolean;
|
|
||||||
};
|
|
||||||
onRemoveFromQueue: (queueId: string) => void;
|
onRemoveFromQueue: (queueId: string) => void;
|
||||||
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => void;
|
|
||||||
onReorder: (participantIds: string[]) => void;
|
onReorder: (participantIds: string[]) => void;
|
||||||
onMakePick?: (participantId: string) => void;
|
onMakePick?: (participantId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -85,45 +75,45 @@ const SortableQueueItem = memo(function SortableQueueItem({
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
{...attributes}
|
{...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"
|
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
|
<div
|
||||||
ref={setActivatorNodeRef}
|
ref={setActivatorNodeRef}
|
||||||
{...listeners}
|
{...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" />
|
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||||
<Badge variant="default" className="text-xs">{index + 1}</Badge>
|
<Badge variant="default" className="text-xs">{index + 1}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-semibold text-sm truncate">{participantName}</p>
|
<p className="font-semibold text-sm truncate">{participantName}</p>
|
||||||
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
|
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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)}
|
onClick={() => onRemove(item.id)}
|
||||||
title="Remove from queue"
|
title="Remove from queue"
|
||||||
>
|
>
|
||||||
<span className="text-lg">×</span>
|
<span className="text-lg">×</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -131,13 +121,8 @@ const SortableQueueItem = memo(function SortableQueueItem({
|
||||||
export const QueueSection = memo(function QueueSection({
|
export const QueueSection = memo(function QueueSection({
|
||||||
queue,
|
queue,
|
||||||
availableParticipants,
|
availableParticipants,
|
||||||
seasonId,
|
|
||||||
teamId,
|
|
||||||
isMyTurn,
|
|
||||||
canPick,
|
canPick,
|
||||||
userAutodraft,
|
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onAutodraftUpdate,
|
|
||||||
onReorder,
|
onReorder,
|
||||||
onMakePick,
|
onMakePick,
|
||||||
}: QueueSectionProps) {
|
}: QueueSectionProps) {
|
||||||
|
|
@ -187,7 +172,7 @@ export const QueueSection = memo(function QueueSection({
|
||||||
items={queueIds}
|
items={queueIds}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5 mb-4">
|
<div className="space-y-1.5">
|
||||||
{queue.map((item, index) => {
|
{queue.map((item, index) => {
|
||||||
const participant = participantMap.get(item.participantId);
|
const participant = participantMap.get(item.participantId);
|
||||||
return (
|
return (
|
||||||
|
|
@ -207,17 +192,6 @@ export const QueueSection = memo(function QueueSection({
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Autodraft Settings */}
|
|
||||||
<AutodraftSettings
|
|
||||||
seasonId={seasonId}
|
|
||||||
teamId={teamId}
|
|
||||||
isEnabled={userAutodraft.isEnabled}
|
|
||||||
mode={userAutodraft.mode}
|
|
||||||
queueOnly={userAutodraft.queueOnly}
|
|
||||||
isMyTurn={isMyTurn}
|
|
||||||
onUpdate={onAutodraftUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
56
app/components/draft/RecentPicksFeed.tsx
Normal file
56
app/components/draft/RecentPicksFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -44,14 +44,11 @@ export const SidebarRecentPicks = memo(function SidebarRecentPicks({ picks }: Si
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{pick.sport.name}
|
{pick.sport.name}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{pick.team.name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { memo, useEffect, useMemo, useState } from "react";
|
import { memo, useEffect, useMemo, useState } from "react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -7,6 +6,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
|
import { DraftPickCell } from "./DraftPickCell";
|
||||||
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
||||||
|
|
||||||
interface TeamRosterViewProps {
|
interface TeamRosterViewProps {
|
||||||
|
|
@ -14,6 +14,7 @@ interface TeamRosterViewProps {
|
||||||
ownerMap?: Record<string, string>;
|
ownerMap?: Record<string, string>;
|
||||||
picks: DraftPick[];
|
picks: DraftPick[];
|
||||||
sports: Array<{ id: string; name: string }>;
|
sports: Array<{ id: string; name: string }>;
|
||||||
|
totalRounds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamRosterView = memo(function TeamRosterView({
|
export const TeamRosterView = memo(function TeamRosterView({
|
||||||
|
|
@ -21,12 +22,12 @@ export const TeamRosterView = memo(function TeamRosterView({
|
||||||
ownerMap = {},
|
ownerMap = {},
|
||||||
picks,
|
picks,
|
||||||
sports,
|
sports,
|
||||||
|
totalRounds,
|
||||||
}: TeamRosterViewProps) {
|
}: TeamRosterViewProps) {
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState(
|
const [selectedTeamId, setSelectedTeamId] = useState(
|
||||||
draftSlots[0]?.team.id ?? ""
|
draftSlots[0]?.team.id ?? ""
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset selection if the selected team is no longer present in the slot list
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
draftSlots.length > 0 &&
|
draftSlots.length > 0 &&
|
||||||
|
|
@ -46,16 +47,42 @@ export const TeamRosterView = memo(function TeamRosterView({
|
||||||
[picksByTeamAndSport, selectedTeamId]
|
[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.filter((s) => (teamPicks?.get(s.id)?.length ?? 0) > 0),
|
||||||
[sports, teamPicks]
|
[sports, teamPicks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalPicks = useMemo(() => {
|
const flexColor =
|
||||||
let count = 0;
|
flexPicksAvailable === 0
|
||||||
teamPicks?.forEach((sp) => (count += sp.length));
|
? "text-muted-foreground"
|
||||||
return count;
|
: flexPicksRemaining <= 0
|
||||||
}, [teamPicks]);
|
? "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) {
|
if (draftSlots.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -68,6 +95,7 @@ export const TeamRosterView = memo(function TeamRosterView({
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Team selector */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="roster-team-select"
|
htmlFor="roster-team-select"
|
||||||
|
|
@ -89,31 +117,76 @@ export const TeamRosterView = memo(function TeamRosterView({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalPicks === 0 ? (
|
{/* Mini dashboard */}
|
||||||
<p className="text-sm text-muted-foreground text-center py-6">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
No picks yet
|
<div className="rounded-lg border bg-card px-3 py-2.5 space-y-0.5">
|
||||||
</p>
|
<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) => {
|
<div className="space-y-3">
|
||||||
const sportPicks = teamPicks?.get(sport.id) ?? [];
|
{draftedSports.map((sport) => {
|
||||||
return (
|
const sportPicks = teamPicks?.get(sport.id) ?? [];
|
||||||
<div key={sport.id}>
|
return (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div key={sport.id}>
|
||||||
<h3 className="text-sm font-semibold">{sport.name}</h3>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
{sport.name}
|
||||||
{sportPicks.length}
|
</p>
|
||||||
</Badge>
|
<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>
|
</div>
|
||||||
<ul className="space-y-1 pl-2">
|
);
|
||||||
{sportPicks.map((pick) => (
|
})}
|
||||||
<li key={pick.id} className="text-sm text-foreground">
|
</div>
|
||||||
{pick.participant.name}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
export type DraftPick = {
|
export type DraftPick = {
|
||||||
id: string;
|
id: string;
|
||||||
|
pickNumber: number;
|
||||||
|
round: number;
|
||||||
|
pickInRound: number;
|
||||||
team: { id: string; name: string };
|
team: { id: string; name: string };
|
||||||
participant: { id: string; name: string };
|
participant: { id: string; name: string };
|
||||||
sport: { id: string; name: string };
|
sport: { id: string; name: string };
|
||||||
|
|
|
||||||
172
app/components/league/CommissionersPanel.stories.tsx
Normal file
172
app/components/league/CommissionersPanel.stories.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
121
app/components/league/CommissionersPanel.tsx
Normal file
121
app/components/league/CommissionersPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/components/league/CreateLeagueCard.stories.tsx
Normal file
15
app/components/league/CreateLeagueCard.stories.tsx
Normal 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 = {};
|
||||||
21
app/components/league/CreateLeagueCard.tsx
Normal file
21
app/components/league/CreateLeagueCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
app/components/league/DraftInfoCard.stories.tsx
Normal file
145
app/components/league/DraftInfoCard.stories.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
208
app/components/league/DraftInfoCard.tsx
Normal file
208
app/components/league/DraftInfoCard.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
52
app/components/league/LeagueAvatar.tsx
Normal file
52
app/components/league/LeagueAvatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
app/components/league/LeagueRow.stories.tsx
Normal file
99
app/components/league/LeagueRow.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
250
app/components/league/LeagueRow.tsx
Normal file
250
app/components/league/LeagueRow.tsx
Normal 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;
|
||||||
|
// 11–13 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} />;
|
||||||
|
}
|
||||||
96
app/components/league/MyLeaguesCard.stories.tsx
Normal file
96
app/components/league/MyLeaguesCard.stories.tsx
Normal 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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
64
app/components/league/MyLeaguesCard.tsx
Normal file
64
app/components/league/MyLeaguesCard.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
app/components/league/SportsSeasonsSummary.stories.tsx
Normal file
230
app/components/league/SportsSeasonsSummary.stories.tsx
Normal 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
285
app/components/league/SportsSeasonsSummary.tsx
Normal file
285
app/components/league/SportsSeasonsSummary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
app/components/league/StandingsPreview.stories.tsx
Normal file
265
app/components/league/StandingsPreview.stories.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
180
app/components/league/StandingsPreview.tsx
Normal file
180
app/components/league/StandingsPreview.tsx
Normal 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 (1–3 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
app/components/league/TeamsPanel.stories.tsx
Normal file
94
app/components/league/TeamsPanel.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
89
app/components/league/TeamsPanel.tsx
Normal file
89
app/components/league/TeamsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/components/marketing/Footer.tsx
Normal file
32
app/components/marketing/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
446
app/components/marketing/LandingPage.tsx
Normal file
446
app/components/marketing/LandingPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuLink,
|
|
||||||
NavigationMenuList,
|
|
||||||
navigationMenuTriggerStyle,
|
|
||||||
} from "~/components/ui/navigation-menu";
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -14,8 +7,9 @@ import {
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "~/components/ui/sheet";
|
} from "~/components/ui/sheet";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router";
|
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router";
|
||||||
import { MenuIcon } from "lucide-react";
|
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
|
@ -27,51 +21,38 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
return (
|
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">
|
<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">
|
<div className="flex h-16 items-center justify-between px-4 md:px-6 lg:px-8">
|
||||||
{/* Logo */}
|
{/* Left: Logo + Nav links */}
|
||||||
<Link to="/" className="flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
<img src="/logo.svg" alt="Brackt" className="h-6" />
|
<Link to="/" className="flex items-center mr-2">
|
||||||
</Link>
|
<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 */}
|
{/* Desktop Right: Support + Admin icons + Auth */}
|
||||||
<NavigationMenu className="hidden md:flex">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<NavigationMenuList>
|
<Link
|
||||||
<NavigationMenuItem>
|
to="/support"
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
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"
|
||||||
<Link to="/">Home</Link>
|
aria-label="Support"
|
||||||
</NavigationMenuLink>
|
>
|
||||||
</NavigationMenuItem>
|
<HelpCircle className="h-5 w-5" />
|
||||||
<NavigationMenuItem>
|
</Link>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
{isAdmin && (
|
||||||
<Link to="/how-to-play">How To Play</Link>
|
<Link
|
||||||
</NavigationMenuLink>
|
to="/admin"
|
||||||
</NavigationMenuItem>
|
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"
|
||||||
<NavigationMenuItem>
|
aria-label="Admin"
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
>
|
||||||
<Link to="/rules">Rules</Link>
|
<Settings className="h-5 w-5" />
|
||||||
</NavigationMenuLink>
|
</Link>
|
||||||
</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">
|
|
||||||
<SignedOut>
|
<SignedOut>
|
||||||
<SignInButton mode="modal">
|
<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">
|
<Button>Sign In</Button>
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</SignInButton>
|
</SignInButton>
|
||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
|
|
@ -79,19 +60,33 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile: icons + Auth + Hamburger */}
|
||||||
<div className="flex md:hidden items-center gap-4">
|
<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>
|
<SignedOut>
|
||||||
<SignInButton mode="modal">
|
<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">
|
<Button>Sign In</Button>
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</SignInButton>
|
</SignInButton>
|
||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<UserButton />
|
<UserButton />
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -105,14 +100,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Menu</SheetTitle>
|
<SheetTitle>Menu</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav className="flex flex-col gap-4 mt-8">
|
<nav aria-label="Mobile navigation" 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>
|
|
||||||
<Link
|
<Link
|
||||||
to="/how-to-play"
|
to="/how-to-play"
|
||||||
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
|
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
|
Rules
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
666
app/components/scoring/BracketTreeView.tsx
Normal file
666
app/components/scoring/BracketTreeView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
app/components/scoring/NbaBracketLayout.tsx
Normal file
116
app/components/scoring/NbaBracketLayout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -10,13 +9,24 @@ import {
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Trophy, Star } from "lucide-react";
|
import { Trophy, Star } from "lucide-react";
|
||||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
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 {
|
interface Participant {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Match {
|
export interface Match {
|
||||||
id: string;
|
id: string;
|
||||||
round: string;
|
round: string;
|
||||||
matchNumber: number;
|
matchNumber: number;
|
||||||
|
|
@ -27,13 +37,14 @@ interface Match {
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
participant1Score: string | null;
|
participant1Score: string | null;
|
||||||
participant2Score: string | null;
|
participant2Score: string | null;
|
||||||
|
isScoring?: boolean;
|
||||||
participant1?: Participant | null;
|
participant1?: Participant | null;
|
||||||
participant2?: Participant | null;
|
participant2?: Participant | null;
|
||||||
winner?: Participant | null;
|
winner?: Participant | null;
|
||||||
loser?: Participant | null;
|
loser?: Participant | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamOwnership {
|
export interface TeamOwnership {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|
@ -43,14 +54,17 @@ interface TeamOwnership {
|
||||||
interface PlayoffBracketProps {
|
interface PlayoffBracketProps {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
rounds: string[]; // Ordered list of round names (earliest first)
|
rounds: string[]; // Ordered list of round names (earliest first)
|
||||||
preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage)
|
bracketTemplateId?: string | null;
|
||||||
participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant
|
preEliminatedParticipants?: { id: string; name: string }[];
|
||||||
partialScoreParticipantIds?: string[]; // Still-competing participants with provisional floor scores
|
participantPoints?: { participantId: string; points: number }[];
|
||||||
|
partialScoreParticipantIds?: string[];
|
||||||
teamOwnerships?: TeamOwnership[];
|
teamOwnerships?: TeamOwnership[];
|
||||||
userParticipantIds?: string[];
|
userParticipantIds?: string[];
|
||||||
showOwnership?: boolean;
|
showOwnership?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: 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. */
|
/** Group matches by round, sorted by matchNumber, in one pass. */
|
||||||
|
|
@ -164,9 +178,29 @@ export function computeEliminatedByRound(
|
||||||
return result;
|
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({
|
export function PlayoffBracket({
|
||||||
matches,
|
matches,
|
||||||
rounds,
|
rounds,
|
||||||
|
bracketTemplateId = null,
|
||||||
preEliminatedParticipants = [],
|
preEliminatedParticipants = [],
|
||||||
participantPoints = [],
|
participantPoints = [],
|
||||||
partialScoreParticipantIds: _partialScoreParticipantIds = [],
|
partialScoreParticipantIds: _partialScoreParticipantIds = [],
|
||||||
|
|
@ -175,27 +209,23 @@ export function PlayoffBracket({
|
||||||
showOwnership = true,
|
showOwnership = true,
|
||||||
title = "Playoff Bracket",
|
title = "Playoff Bracket",
|
||||||
description,
|
description,
|
||||||
|
mode = "bracket",
|
||||||
}: PlayoffBracketProps) {
|
}: PlayoffBracketProps) {
|
||||||
const userParticipantSet = new Set(userParticipantIds);
|
const userParticipantSet = new Set(userParticipantIds);
|
||||||
const ownershipMap = new Map<string, TeamOwnership>();
|
const ownershipMap = new Map<string, TeamOwnership>();
|
||||||
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
|
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
|
||||||
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
|
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 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 thirdPlaceRound = template?.rounds
|
||||||
const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`);
|
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||||
if (!feeder) return "TBD";
|
?.name;
|
||||||
return `Winner of ${feeder.round} M${feeder.matchNumber}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build elimination rankings: collect losers per round, then assign rank labels.
|
// Build elimination rankings
|
||||||
// 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.
|
|
||||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||||
let _hasScore = false;
|
|
||||||
let bracketWinner: Participant | null = null;
|
let bracketWinner: Participant | null = null;
|
||||||
|
|
||||||
const lastRound = rounds[rounds.length - 1];
|
const lastRound = rounds[rounds.length - 1];
|
||||||
|
|
@ -204,7 +234,6 @@ export function PlayoffBracket({
|
||||||
: null;
|
: null;
|
||||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||||
|
|
||||||
// Compute which participants are eliminated in which round, handling double-chance.
|
|
||||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
|
|
@ -215,7 +244,6 @@ export function PlayoffBracket({
|
||||||
match.loserId === match.participant1Id
|
match.loserId === match.participant1Id
|
||||||
? match.participant1Score
|
? match.participant1Score
|
||||||
: match.participant2Score;
|
: match.participant2Score;
|
||||||
if (loserScore) _hasScore = true;
|
|
||||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||||
losersByRound.get(match.round)?.push({
|
losersByRound.get(match.round)?.push({
|
||||||
participant: match.loser,
|
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>();
|
const allBracketParticipantIds = new Set<string>();
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||||
}
|
}
|
||||||
const rankedEntries: EliminatedEntry[] = [];
|
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--) {
|
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||||
const roundName = rounds[ri];
|
const roundName = rounds[ri];
|
||||||
const roundLosers = losersByRound.get(roundName) || [];
|
const roundLosers = losersByRound.get(roundName) || [];
|
||||||
|
|
@ -248,7 +272,6 @@ export function PlayoffBracket({
|
||||||
nextRank += totalMatchesInRound;
|
nextRank += totalMatchesInRound;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude pre-eliminated participants already ranked via bracket match losers
|
|
||||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||||
const filteredPreEliminated = preEliminatedParticipants.filter(
|
const filteredPreEliminated = preEliminatedParticipants.filter(
|
||||||
|
|
@ -257,7 +280,6 @@ export function PlayoffBracket({
|
||||||
|
|
||||||
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0;
|
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>();
|
const participantMap = new Map<string, Participant>();
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.participant1) participantMap.set(match.participant1.id, match.participant1);
|
if (match.participant1) participantMap.set(match.participant1.id, match.participant1);
|
||||||
|
|
@ -268,17 +290,16 @@ export function PlayoffBracket({
|
||||||
.map((id) => participantMap.get(id))
|
.map((id) => participantMap.get(id))
|
||||||
.filter((p): p is Participant => p !== undefined);
|
.filter((p): p is Participant => p !== undefined);
|
||||||
|
|
||||||
// Hide participants with 0 points that nobody drafted — they add noise without value.
|
|
||||||
const isDraftedOrScoring = (participantId: string) =>
|
const isDraftedOrScoring = (participantId: string) =>
|
||||||
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
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 winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false;
|
||||||
const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined;
|
const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined;
|
||||||
const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined;
|
const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">{title}</h2>
|
<h2 className="text-xl font-semibold">{title}</h2>
|
||||||
{description && (
|
{description && (
|
||||||
|
|
@ -295,177 +316,54 @@ export function PlayoffBracket({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{rounds.map((round) => {
|
{mode === "bracket" && (template?.phases ? (
|
||||||
const roundMatches = matchesByRound.get(round) || [];
|
<TabbedBracketLayout
|
||||||
if (roundMatches.length === 0) return null;
|
rounds={rounds}
|
||||||
|
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||||
return (
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
<div key={round}>
|
userParticipantIds={userParticipantSet}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
phases={template.phases}
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
{round}
|
/>
|
||||||
</h3>
|
) : template?.conferenceGroups ? (
|
||||||
<div className="flex-1 border-t border-border" />
|
<NbaBracketLayout
|
||||||
<span className="text-xs text-muted-foreground">
|
matches={matches}
|
||||||
{roundMatches.length}{" "}
|
rounds={rounds}
|
||||||
{roundMatches.length === 1 ? "match" : "matches"}
|
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||||
</span>
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
</div>
|
userParticipantIds={userParticipantSet}
|
||||||
|
conferenceGroups={template.conferenceGroups}
|
||||||
<div className="grid md:grid-cols-2 gap-2">
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
{roundMatches.map((match) => {
|
/>
|
||||||
const p1IsWinner =
|
) : (
|
||||||
match.isComplete && match.winnerId === match.participant1Id;
|
<>
|
||||||
const p2IsWinner =
|
{/* ── Desktop ── */}
|
||||||
match.isComplete && match.winnerId === match.participant2Id;
|
<div className="hidden md:block">
|
||||||
const p1IsLoser =
|
<BracketTreeView
|
||||||
match.isComplete && match.loserId === match.participant1Id;
|
rounds={rounds}
|
||||||
const p2IsLoser =
|
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||||||
match.isComplete && match.loserId === match.participant2Id;
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
|
userParticipantIds={userParticipantSet}
|
||||||
const p1Name =
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* In Contention */}
|
{/* ── Mobile ── */}
|
||||||
{activeParticipants.length > 0 && (
|
<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">
|
<Card className="border-green-500/30">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
|
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
|
||||||
|
|
@ -525,134 +423,127 @@ export function PlayoffBracket({
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Final Rankings / Eliminated Teams */}
|
{/* ── Final Rankings ── */}
|
||||||
{showRankings && (
|
{mode === "rankings" && showRankings && (
|
||||||
<Card className="border-electric/30">
|
<Card className="gap-2">
|
||||||
<CardHeader>
|
<CardHeader className="px-3 sm:px-6 pb-2">
|
||||||
<CardTitle className="text-electric flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trophy className="h-5 w-5" />
|
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
|
||||||
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
<h2 className="text-xl font-bold leading-none tracking-tight">
|
||||||
</CardTitle>
|
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-3 sm:px-6">
|
||||||
<Table>
|
<div className="space-y-3">
|
||||||
<TableHeader>
|
{bracketWinner && (
|
||||||
<TableRow>
|
<div className={rankingRowCls(1)}>
|
||||||
<TableHead className="w-20">Rank</TableHead>
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<TableHead>Participant</TableHead>
|
<TeamAvatar
|
||||||
{showOwnership && (
|
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
|
||||||
<TableHead className="w-40 pl-6">Drafted By</TableHead>
|
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
|
||||||
)}
|
/>
|
||||||
{pointsMap.size > 0 && (
|
<div className="min-w-0">
|
||||||
<TableHead className="text-right w-16">Pts</TableHead>
|
<p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
|
||||||
)}
|
{bracketWinner.name}
|
||||||
</TableRow>
|
{winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||||||
</TableHeader>
|
</p>
|
||||||
<TableBody>
|
{winnerOwnership?.ownerName && (
|
||||||
{bracketWinner && (
|
<p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
|
||||||
<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" />
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
{showOwnership && (
|
</div>
|
||||||
<TableCell className="pl-6">
|
<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">
|
||||||
{winnerOwnership ? (
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} />
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||||
) : (
|
<span className="text-2xl font-bold leading-none">1</span>
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
</div>
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{pointsMap.size > 0 && (
|
{pointsMap.size > 0 && (
|
||||||
<TableCell className="text-right font-semibold text-electric">
|
<>
|
||||||
{winnerPts ?? "—"}
|
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||||
</TableCell>
|
<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) => {
|
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
|
||||||
const isOwned = userParticipantSet.has(entry.participant.id);
|
const isOwned = userParticipantSet.has(entry.participant.id);
|
||||||
const pts = pointsMap.get(entry.participant.id);
|
const pts = pointsMap.get(entry.participant.id);
|
||||||
return (
|
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
|
||||||
<TableRow
|
return (
|
||||||
key={entry.participant.id}
|
<div key={entry.participant.id} className={rankingRowCls(numRank)}>
|
||||||
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
>
|
<TeamAvatar
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
teamId={entry.ownership?.teamId ?? entry.participant.id}
|
||||||
{entry.rankLabel}
|
teamName={entry.ownership?.teamName ?? entry.participant.name}
|
||||||
</TableCell>
|
/>
|
||||||
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
|
<div className="min-w-0">
|
||||||
{entry.participant.name}
|
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||||
{isOwned && (
|
{entry.participant.name}
|
||||||
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
{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>
|
</div>
|
||||||
{showOwnership && (
|
</div>
|
||||||
<TableCell className="pl-6">
|
<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">
|
||||||
{entry.ownership ? (
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
<TeamOwnerBadge
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||||||
teamName={entry.ownership.teamName}
|
<span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
|
||||||
ownerName={entry.ownership.ownerName}
|
</div>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{pointsMap.size > 0 && (
|
{pointsMap.size > 0 && (
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
<>
|
||||||
{pts ?? "—"}
|
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||||||
</TableCell>
|
<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) => {
|
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
|
||||||
const isOwned = userParticipantSet.has(p.id);
|
const isOwned = userParticipantSet.has(p.id);
|
||||||
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null;
|
const ownership = ownershipMap.get(p.id);
|
||||||
const pts = pointsMap.get(p.id);
|
const pts = pointsMap.get(p.id);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<div key={p.id} className={rankingRowCls(undefined)}>
|
||||||
key={p.id}
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-60" : "opacity-40"}
|
<TeamAvatar
|
||||||
>
|
teamId={ownership?.teamId ?? p.id}
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">—</TableCell>
|
teamName={ownership?.teamName ?? p.name}
|
||||||
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
|
/>
|
||||||
{p.name}
|
<div className="min-w-0">
|
||||||
{isOwned && (
|
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||||||
<Star className="inline ml-1.5 h-3 w-3 fill-current 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>
|
</div>
|
||||||
{showOwnership && (
|
</div>
|
||||||
<TableCell className="pl-6">
|
{pointsMap.size > 0 && (
|
||||||
{ownership ? (
|
<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">
|
||||||
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
|
<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-xs text-muted-foreground">-</span>
|
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
|
||||||
)}
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pointsMap.size > 0 && (
|
</div>
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
);
|
||||||
{pts ?? "—"}
|
})}
|
||||||
</TableCell>
|
</div>
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
@ -661,3 +552,4 @@ export function PlayoffBracket({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,10 +92,12 @@ interface SportSeasonDisplayProps {
|
||||||
scoringPattern: ScoringPattern;
|
scoringPattern: ScoringPattern;
|
||||||
sportSeasonName: string;
|
sportSeasonName: string;
|
||||||
sportName: string;
|
sportName: string;
|
||||||
|
bracketMode?: "bracket" | "rankings";
|
||||||
|
|
||||||
// Playoff data
|
// Playoff data
|
||||||
playoffMatches?: Match[];
|
playoffMatches?: Match[];
|
||||||
playoffRounds?: string[];
|
playoffRounds?: string[];
|
||||||
|
bracketTemplateId?: string | null;
|
||||||
preEliminatedParticipants?: { id: string; name: string }[];
|
preEliminatedParticipants?: { id: string; name: string }[];
|
||||||
participantPoints?: { participantId: string; points: number }[];
|
participantPoints?: { participantId: string; points: number }[];
|
||||||
partialScoreParticipantIds?: string[];
|
partialScoreParticipantIds?: string[];
|
||||||
|
|
@ -122,8 +124,10 @@ export function SportSeasonDisplay({
|
||||||
scoringPattern,
|
scoringPattern,
|
||||||
sportSeasonName,
|
sportSeasonName,
|
||||||
sportName: _sportName,
|
sportName: _sportName,
|
||||||
|
bracketMode = "bracket",
|
||||||
playoffMatches = [],
|
playoffMatches = [],
|
||||||
playoffRounds = [],
|
playoffRounds = [],
|
||||||
|
bracketTemplateId = null,
|
||||||
preEliminatedParticipants = [],
|
preEliminatedParticipants = [],
|
||||||
participantPoints = [],
|
participantPoints = [],
|
||||||
partialScoreParticipantIds = [],
|
partialScoreParticipantIds = [],
|
||||||
|
|
@ -147,6 +151,7 @@ export function SportSeasonDisplay({
|
||||||
<PlayoffBracket
|
<PlayoffBracket
|
||||||
matches={playoffMatches}
|
matches={playoffMatches}
|
||||||
rounds={playoffRounds}
|
rounds={playoffRounds}
|
||||||
|
bracketTemplateId={bracketTemplateId}
|
||||||
preEliminatedParticipants={preEliminatedParticipants}
|
preEliminatedParticipants={preEliminatedParticipants}
|
||||||
participantPoints={participantPoints}
|
participantPoints={participantPoints}
|
||||||
partialScoreParticipantIds={partialScoreParticipantIds}
|
partialScoreParticipantIds={partialScoreParticipantIds}
|
||||||
|
|
@ -154,6 +159,7 @@ export function SportSeasonDisplay({
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
showOwnership={showOwnership}
|
showOwnership={showOwnership}
|
||||||
title="Playoff Bracket"
|
title="Playoff Bracket"
|
||||||
|
mode={bracketMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
246
app/components/scoring/TabbedBracketLayout.tsx
Normal file
246
app/components/scoring/TabbedBracketLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,6 @@ interface Props {
|
||||||
teamOwnerships: Record<string, TeamOwnership>;
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
userParticipantIds: string[];
|
userParticipantIds: string[];
|
||||||
showOtLosses?: boolean;
|
showOtLosses?: boolean;
|
||||||
participantEvs?: Record<string, string>;
|
|
||||||
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
||||||
playoffSpots?: number;
|
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. */
|
/** "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,
|
teamOwnerships,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
showOtLosses,
|
showOtLosses,
|
||||||
participantEvs,
|
|
||||||
hasEvs,
|
|
||||||
}: {
|
}: {
|
||||||
sections: TableSection[];
|
sections: TableSection[];
|
||||||
teamOwnerships: Record<string, TeamOwnership>;
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
userParticipantIds: string[];
|
userParticipantIds: string[];
|
||||||
showOtLosses: boolean;
|
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)
|
// 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 (
|
return (
|
||||||
<div className="overflow-x-auto -mx-6 px-6">
|
<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">L10</th>
|
||||||
<th className="text-right py-1.5 px-2 w-12">STK</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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -299,7 +293,6 @@ function StandingsTable({
|
||||||
|
|
||||||
const isUserTeam = userParticipantIds.includes(row.participantId);
|
const isUserTeam = userParticipantIds.includes(row.participantId);
|
||||||
const ownership = teamOwnerships[row.participantId];
|
const ownership = teamOwnerships[row.participantId];
|
||||||
const ev = participantEvs[row.participantId];
|
|
||||||
|
|
||||||
sectionRows.push(
|
sectionRows.push(
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -369,17 +362,6 @@ function StandingsTable({
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -399,13 +381,11 @@ export function RegularSeasonStandings({
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
showOtLosses = false,
|
showOtLosses = false,
|
||||||
participantEvs = {},
|
|
||||||
playoffSpots = 8,
|
playoffSpots = 8,
|
||||||
displayMode = "flat",
|
displayMode = "flat",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (standings.length === 0) return null;
|
if (standings.length === 0) return null;
|
||||||
|
|
||||||
const hasEvs = Object.keys(participantEvs).length > 0;
|
|
||||||
const lastSyncedAt = standings
|
const lastSyncedAt = standings
|
||||||
.map((s) => s.syncedAt)
|
.map((s) => s.syncedAt)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
@ -419,7 +399,7 @@ export function RegularSeasonStandings({
|
||||||
? buildMlbSections(standings)
|
? buildMlbSections(standings)
|
||||||
: buildFlatSections(standings, playoffSpots);
|
: buildFlatSections(standings, playoffSpots);
|
||||||
|
|
||||||
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs };
|
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
136
app/components/sport-season/UpcomingEventsCard.stories.tsx
Normal file
136
app/components/sport-season/UpcomingEventsCard.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
291
app/components/sport-season/UpcomingEventsCard.tsx
Normal file
291
app/components/sport-season/UpcomingEventsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
app/components/sport-season/UpcomingEventsCardFull.stories.tsx
Normal file
121
app/components/sport-season/UpcomingEventsCardFull.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
152
app/components/standings/PointProgressionChart.stories.tsx
Normal file
152
app/components/standings/PointProgressionChart.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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 { 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 {
|
interface Team {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChartDataPoint {
|
export interface ChartDataPoint {
|
||||||
date: string;
|
date: string;
|
||||||
[teamName: string]: string | number;
|
[teamName: string]: string | number;
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +86,8 @@ function formatDate(dateStr: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) {
|
export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) {
|
||||||
|
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
if (chartData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -62,46 +121,45 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||||
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<div className="flex-1 min-w-0">
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<XAxis
|
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||||
dataKey="date"
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
tickFormatter={formatDate}
|
<XAxis
|
||||||
className="text-xs"
|
dataKey="date"
|
||||||
tick={{ fill: 'currentColor' }}
|
tickFormatter={formatDate}
|
||||||
/>
|
className="text-xs"
|
||||||
<YAxis
|
tick={{ fill: 'currentColor' }}
|
||||||
label={{ value: 'Points', angle: -90, position: 'insideLeft' }}
|
/>
|
||||||
className="text-xs"
|
<YAxis
|
||||||
tick={{ fill: 'currentColor' }}
|
width={40}
|
||||||
/>
|
className="text-xs"
|
||||||
<Tooltip
|
tick={{ fill: 'currentColor' }}
|
||||||
contentStyle={{
|
/>
|
||||||
backgroundColor: 'hsl(var(--background))',
|
{teams.map((team, index) => (
|
||||||
border: '1px solid hsl(var(--border))',
|
<Line
|
||||||
borderRadius: '6px',
|
key={team.id}
|
||||||
}}
|
type="monotone"
|
||||||
labelFormatter={(label) => formatDate(label as string)}
|
dataKey={team.name}
|
||||||
formatter={(value: number) => [value.toFixed(2), '']}
|
stroke={COLORS[index % COLORS.length]}
|
||||||
/>
|
strokeWidth={2}
|
||||||
<Legend
|
dot={false}
|
||||||
wrapperStyle={{ paddingTop: '20px' }}
|
activeDot={false}
|
||||||
/>
|
strokeOpacity={hoveredLine === null || hoveredLine === team.name ? 1 : 0.1}
|
||||||
{teams.map((team, index) => (
|
isAnimationActive={false}
|
||||||
<Line
|
connectNulls
|
||||||
key={team.id}
|
/>
|
||||||
type="monotone"
|
))}
|
||||||
dataKey={team.name}
|
</LineChart>
|
||||||
stroke={COLORS[index % COLORS.length]}
|
</ResponsiveContainer>
|
||||||
strokeWidth={2}
|
</div>
|
||||||
dot={{ r: 3 }}
|
<CustomLegend
|
||||||
activeDot={{ r: 5 }}
|
payload={teamsByRank.map((team) => ({ value: team.name, color: COLORS[teams.findIndex(t => t.id === team.id) % COLORS.length] }))}
|
||||||
connectNulls
|
onHover={setHoveredLine}
|
||||||
/>
|
onLeave={() => setHoveredLine(null)}
|
||||||
))}
|
/>
|
||||||
</LineChart>
|
</div>
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
192
app/components/standings/RecentScoresCard.stories.tsx
Normal file
192
app/components/standings/RecentScoresCard.stories.tsx
Normal 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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
116
app/components/standings/RecentScoresCard.tsx
Normal file
116
app/components/standings/RecentScoresCard.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
app/components/ui/BracktGradients.tsx
Normal file
21
app/components/ui/BracktGradients.tsx
Normal 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 + 0→24 matches the Lucide icon viewBox so horizontal
|
||||||
|
strokes (zero bounding-box height) don't produce a degenerate gradient. */}
|
||||||
|
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="24" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stopColor="#adf661" />
|
||||||
|
<stop offset="100%" stopColor="#2ce1c1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
app/components/ui/GradientIcon.tsx
Normal file
30
app/components/ui/GradientIcon.tsx
Normal 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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
app/components/ui/SectionCardHeader.tsx
Normal file
28
app/components/ui/SectionCardHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "app/lib/utils"
|
import { cn } from "app/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,38 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "app/lib/utils"
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(cardVariants({ variant }), className)}
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { cardVariants }
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface TeamOwnerBadgeProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
ownerName?: string;
|
ownerName?: string;
|
||||||
align?: "left" | "right"; // right = avatar on the right side for the right column
|
align?: "left" | "right";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeamOwnerBadge({
|
export function TeamOwnerBadge({
|
||||||
|
|
@ -11,14 +26,23 @@ export function TeamOwnerBadge({
|
||||||
ownerName,
|
ownerName,
|
||||||
align = "left",
|
align = "left",
|
||||||
}: TeamOwnerBadgeProps) {
|
}: TeamOwnerBadgeProps) {
|
||||||
const initial = teamName.charAt(0).toUpperCase();
|
const initials =
|
||||||
const colorClass = getAvatarColor(teamName);
|
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 = (
|
const avatar = (
|
||||||
<div
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,37 @@ export interface BracketRegion {
|
||||||
playIns: BracketPlayIn[];
|
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 {
|
export interface BracketTemplate {
|
||||||
/** Unique identifier for this template */
|
/** Unique identifier for this template */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -88,6 +119,17 @@ export interface BracketTemplate {
|
||||||
* Length must equal totalTeams.
|
* Length must equal totalTeams.
|
||||||
*/
|
*/
|
||||||
participantLabels?: string[];
|
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 (1–16) */
|
/** All seed numbers in a standard 16-team regional bracket (1–16) */
|
||||||
|
|
@ -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
|
* 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
|
* 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
|
* East — 16 direct seeds, no play-ins
|
||||||
* South — 15 direct seeds, 16-seed play-in
|
* South — 15 direct seeds, 16-seed play-in
|
||||||
* West — 15 direct seeds, 11-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 1", "West 2", "West 3", "West 4", "West 5",
|
||||||
"West 6", "West 7", "West 8", "West 9", "West 10",
|
"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
3
app/lib/brand.ts
Normal 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";
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
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: {
|
export async function createDraftPick(data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|
@ -103,6 +104,129 @@ export async function getDraftedParticipantsBySportsSeason(
|
||||||
return map;
|
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) {
|
export async function deleteAllDraftPicks(seasonId: string) {
|
||||||
const db = database();
|
const db = database();
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||||
import { doesLoserAdvance } from "~/models/playoff-match";
|
import { doesLoserAdvance } from "~/models/playoff-match";
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -393,6 +394,7 @@ export async function processMatchResult(
|
||||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
||||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||||
}
|
}
|
||||||
|
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||||
} else {
|
} else {
|
||||||
const config = getRoundConfig(round, bracketTemplateId);
|
const config = getRoundConfig(round, bracketTemplateId);
|
||||||
|
|
||||||
|
|
@ -402,15 +404,37 @@ export async function processMatchResult(
|
||||||
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
|
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
|
||||||
);
|
);
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
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) {
|
} else if (config.winnerFloor === null) {
|
||||||
// Finalization round: winner and loser both get their exact positions.
|
// Finalization round: winner and loser both get their exact positions.
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
|
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 {
|
} else {
|
||||||
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
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
|
* isPartialScore=true means the participant is still alive and this placement
|
||||||
* is their guaranteed minimum floor — it will be replaced as they advance or
|
* is their guaranteed minimum floor — it will be replaced as they advance or
|
||||||
* when they are finally eliminated.
|
* 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(
|
async function upsertParticipantResult(
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
|
@ -443,7 +471,7 @@ async function upsertParticipantResult(
|
||||||
finalPosition: number,
|
finalPosition: number,
|
||||||
db: ReturnType<typeof database>,
|
db: ReturnType<typeof database>,
|
||||||
isPartialScore = false
|
isPartialScore = false
|
||||||
): Promise<void> {
|
): Promise<number | null> {
|
||||||
const existing = await db.query.participantResults.findFirst({
|
const existing = await db.query.participantResults.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.participantResults.participantId, participantId),
|
eq(schema.participantResults.participantId, participantId),
|
||||||
|
|
@ -455,7 +483,7 @@ async function upsertParticipantResult(
|
||||||
// Never un-finalize: if the row is already finalized (isPartialScore=false),
|
// 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
|
// a call with isPartialScore=true must not overwrite it (e.g. a re-run of
|
||||||
// processPlayoffEvent after a manual finalization).
|
// processPlayoffEvent after a manual finalization).
|
||||||
if (!existing.isPartialScore && isPartialScore) return;
|
if (!existing.isPartialScore && isPartialScore) return null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(schema.participantResults)
|
.update(schema.participantResults)
|
||||||
|
|
@ -465,6 +493,7 @@ async function upsertParticipantResult(
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.participantResults.id, existing.id));
|
.where(eq(schema.participantResults.id, existing.id));
|
||||||
|
return existing.finalPosition ?? 0;
|
||||||
} else {
|
} else {
|
||||||
await db.insert(schema.participantResults).values({
|
await db.insert(schema.participantResults).values({
|
||||||
participantId,
|
participantId,
|
||||||
|
|
@ -472,6 +501,7 @@ async function upsertParticipantResult(
|
||||||
finalPosition,
|
finalPosition,
|
||||||
isPartialScore,
|
isPartialScore,
|
||||||
});
|
});
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
258
app/models/team-score-events.ts
Normal file
258
app/models/team-score-events.ts
Normal 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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
24
app/root.tsx
24
app/root.tsx
|
|
@ -16,6 +16,8 @@ import { NavigationProgress } from "~/components/NavigationProgress";
|
||||||
import { ClerkProvider } from "@clerk/react-router";
|
import { ClerkProvider } from "@clerk/react-router";
|
||||||
import { dark } from "@clerk/themes";
|
import { dark } from "@clerk/themes";
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
|
import { BracktGradients } from "~/components/ui/BracktGradients";
|
||||||
|
import { Footer } from "~/components/marketing/Footer";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdminByClerkId } from "~/models/user";
|
||||||
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
|
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 = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: "icon", href: "/favicon.ico", sizes: "48x48" },
|
{ rel: "icon", href: "/favicon.ico?v=20260402", sizes: "48x48" },
|
||||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
{ rel: "icon", href: "/favicon.svg?v=20260402", type: "image/svg+xml" },
|
||||||
{ rel: "icon", href: "/favicon-96x96.png", type: "image/png", sizes: "96x96" },
|
{ rel: "icon", href: "/favicon-96x96.png?v=20260402", type: "image/png", sizes: "96x96" },
|
||||||
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
|
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png?v=20260402" },
|
||||||
{ rel: "manifest", href: "/site.webmanifest" },
|
{ rel: "manifest", href: "/site.webmanifest?v=20260402" },
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
{
|
{
|
||||||
rel: "preconnect",
|
rel: "preconnect",
|
||||||
|
|
@ -48,7 +50,7 @@ export const links: Route.LinksFunction = () => [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: "stylesheet",
|
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 />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<BracktGradients />
|
||||||
{children}
|
{children}
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
|
|
@ -81,9 +84,12 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
||||||
{isDraftRoute ? (
|
{isDraftRoute ? (
|
||||||
<Outlet />
|
<Outlet />
|
||||||
) : (
|
) : (
|
||||||
<main>
|
<>
|
||||||
<Outlet />
|
<main>
|
||||||
</main>
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export default [
|
||||||
route("rules", "routes/rules.tsx"),
|
route("rules", "routes/rules.tsx"),
|
||||||
route("support", "routes/support.tsx"),
|
route("support", "routes/support.tsx"),
|
||||||
route("upcoming-events", "routes/upcoming-events.tsx"),
|
route("upcoming-events", "routes/upcoming-events.tsx"),
|
||||||
|
route("privacy-policy", "routes/privacy-policy.tsx"),
|
||||||
route("test-socket", "routes/test-socket.tsx"),
|
route("test-socket", "routes/test-socket.tsx"),
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
|
|
|
||||||
|
|
@ -421,7 +421,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
|
matchId,
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
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) {
|
for (const match of sortedMatches) {
|
||||||
|
if (!match.winnerId || !match.loserId) continue;
|
||||||
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
|
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
|
||||||
await processMatchResult(
|
await processMatchResult(
|
||||||
{
|
{
|
||||||
round: match.round,
|
round: match.round,
|
||||||
winnerId: match.winnerId ?? "",
|
winnerId: match.winnerId,
|
||||||
loserId: match.loserId ?? "",
|
loserId: match.loserId,
|
||||||
isScoring,
|
isScoring,
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
|
eventId: event.id,
|
||||||
|
eventName: event.name ?? undefined,
|
||||||
|
matchId: match.id,
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Link, useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { addDays, subDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
|
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
|
import { LandingPage } from "~/components/marketing/LandingPage";
|
||||||
import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league";
|
import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league";
|
||||||
import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season";
|
import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season";
|
||||||
import { findTeamByOwnerAndSeason } from "~/models/team";
|
import { findTeamByOwnerAndSeason } from "~/models/team";
|
||||||
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
||||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
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 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 { toEventSortKey } from "~/lib/date-utils";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -39,14 +39,12 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
|
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch leagues where user has a team in the current season
|
|
||||||
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
|
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
||||||
const dateToStr = addDays(today, 30).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(
|
const leaguesWithData = await Promise.all(
|
||||||
leagues.map(async (league) => {
|
leagues.map(async (league) => {
|
||||||
const [season, seasonWithSports] = await Promise.all([
|
const [season, seasonWithSports] = await Promise.all([
|
||||||
|
|
@ -54,16 +52,59 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
findCurrentSeasonWithSports(league.id),
|
findCurrentSeasonWithSports(league.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build calendar events for this league
|
const numSports = seasonWithSports?.seasonSports?.length ?? 0;
|
||||||
const calendarEvents: CalendarPanelEvent[] = [];
|
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) {
|
if (league.currentSeasonId && seasonWithSports?.seasonSports) {
|
||||||
const myTeam = await findTeamByOwnerAndSeason(userId, league.currentSeasonId);
|
const myTeam = await findTeamByOwnerAndSeason(userId, league.currentSeasonId);
|
||||||
|
|
||||||
if (myTeam) {
|
if (myTeam) {
|
||||||
const participantsBySportsSeason = await getDraftedParticipantsBySportsSeason(
|
// Fetch standing, completion %, and calendar events in parallel
|
||||||
myTeam.id,
|
const [standing, pct, participantsBySportsSeason] = await Promise.all([
|
||||||
league.currentSeasonId
|
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(
|
const perSeasonEvents = await Promise.all(
|
||||||
seasonWithSports.seasonSports.map(async ({ sportsSeason: ss }) => {
|
seasonWithSports.seasonSports.map(async ({ sportsSeason: ss }) => {
|
||||||
|
|
@ -96,6 +137,14 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
return {
|
return {
|
||||||
...league,
|
...league,
|
||||||
currentSeason: season,
|
currentSeason: season,
|
||||||
|
numSports,
|
||||||
|
currentRank,
|
||||||
|
totalPoints,
|
||||||
|
previousRank,
|
||||||
|
completionPercentage,
|
||||||
|
picksUntilMyTurn,
|
||||||
|
draftPosition,
|
||||||
|
draftDateTime: season?.draftDateTime?.toISOString() ?? null,
|
||||||
calendarEvents,
|
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) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
const { leagues, isLoggedIn, upcomingCalendarEvents } = loaderData;
|
const { leagues, isLoggedIn, upcomingCalendarEvents } = loaderData;
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -128,90 +183,54 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return <LandingPage />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<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>
|
lg+: 3-col grid, left column (My Leagues + Create) spans 2, right (Events) spans 1.
|
||||||
<Button asChild>
|
mobile/tablet: single column stacked in order: My Leagues, Upcoming Events, Create League.
|
||||||
<Link to="/leagues/new">Create New League</Link>
|
*/}
|
||||||
</Button>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { Button } from "app/components/ui/button";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -27,8 +28,8 @@ export default function HowToPlay() {
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-semibold mb-3">The Big Picture</h2>
|
<h2 className="text-2xl font-semibold mb-3">The Big Picture</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Before the season starts, every participant in your league holds a
|
Before the season starts, everyone in the league participates in a
|
||||||
draft — picking real athletes and teams across a set of sports
|
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
|
chosen by your commissioner. Over the following months, those picks
|
||||||
compete in their actual seasons. When it's all said and done,
|
compete in their actual seasons. When it's all said and done,
|
||||||
whoever earned the most points from their picks wins.
|
whoever earned the most points from their picks wins.
|
||||||
|
|
@ -39,17 +40,19 @@ export default function HowToPlay() {
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-semibold mb-3">Building Your Roster</h2>
|
<h2 className="text-2xl font-semibold mb-3">Building Your Roster</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Your league includes a set of sports selected by the commissioner —
|
Your league includes a set of sports selected by the commissioner, such as
|
||||||
think NFL, NBA, golf, Formula 1, tennis, and more. Your roster has
|
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 dedicated spot for each sport, so you need to draft at least
|
||||||
one pick from every sport in the league.
|
one pick from every sport in the league.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Extra draft rounds beyond the number of sports create{" "}
|
Extra draft rounds beyond the number of sports create{" "}
|
||||||
<span className="font-semibold text-foreground">flex spots</span>.
|
<span className="font-semibold text-foreground">flex spots</span>.
|
||||||
Flex picks can come from any sport — great for grabbing a second
|
Flex picks can come from any sport. These are great for grabbing a
|
||||||
player in a sport where you see value, or doubling down on a safe
|
second (or even a third and a fourth!) player in a sport where you
|
||||||
bet.
|
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>
|
</p>
|
||||||
<div className="bg-muted rounded-lg p-4 text-sm text-muted-foreground">
|
<div className="bg-muted rounded-lg p-4 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -65,7 +68,7 @@ export default function HowToPlay() {
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-semibold mb-3">How Points Work</h2>
|
<h2 className="text-2xl font-semibold mb-3">How Points Work</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<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
|
8th. The better they finish, the more you earn. Your commissioner
|
||||||
can adjust point values before the draft, but here's what a typical
|
can adjust point values before the draft, but here's what a typical
|
||||||
league looks like:
|
league looks like:
|
||||||
|
|
@ -124,24 +127,27 @@ export default function HowToPlay() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
If nobody in your league drafted a team that finishes in the top 8,
|
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
|
those points aren't awarded to anyone in your league. In other
|
||||||
off.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Major-Based Sports */}
|
{/* Major-Based Sports */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-semibold mb-3">
|
<h2 className="text-2xl font-semibold mb-3">Sports with Majors</h2>
|
||||||
Golf, Tennis, CS2 — Sports with Majors
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Some sports don't have a single championship — they have multiple
|
Some sports don't have a single championship, they have multiple
|
||||||
major tournaments spread throughout the year. For these sports,
|
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{" "}
|
players earn{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
qualifying points (QP)
|
qualifying points (QP)
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
at each major:
|
at each one:
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-muted rounded-lg overflow-hidden mb-4">
|
<div className="bg-muted rounded-lg overflow-hidden mb-4">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|
@ -215,71 +221,89 @@ export default function HowToPlay() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* The Draft */}
|
{/* The Draft Room */}
|
||||||
<section>
|
<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">
|
<p className="text-muted-foreground mb-6">
|
||||||
The draft is a snake draft — pick order reverses every round. If
|
Because Brackt is a "draft and done" league, the draft is your only
|
||||||
you pick last in round 1, you pick first in round 2. No trading
|
opportunity to take action. Once the final pick is made, your roster
|
||||||
picks, and once you make a selection it's yours for the whole
|
is locked for the entire year. There are no trades, waivers, or
|
||||||
season.
|
mid-season pickups. Your success depends entirely on the strategy
|
||||||
|
you execute during the draft.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Chess Clock */}
|
{/* Timing Mechanics */}
|
||||||
<div className="border rounded-lg p-5 mb-6 space-y-3">
|
<div className="space-y-3 mb-6">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">Timing Mechanics</h3>
|
||||||
The Chess Clock — A Brackt Original
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Most fantasy drafts use a simple countdown: you get X seconds per
|
The draft uses a snake format where the pick order reverses every
|
||||||
pick and that's it. Brackt does it differently with a{" "}
|
round. You can configure the timing of the draft using one of two
|
||||||
<span className="font-semibold text-foreground">
|
primary mechanics:
|
||||||
Fischer increment timer
|
|
||||||
</span>
|
|
||||||
, borrowed from competitive chess.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<ul className="space-y-2 text-muted-foreground pl-4">
|
||||||
Every participant starts the draft with a personal time bank.
|
<li>
|
||||||
Your clock counts down while it's your turn. When you make a
|
<span className="font-semibold text-foreground">
|
||||||
pick,{" "}
|
Standard Clock:
|
||||||
<span className="font-semibold text-foreground">
|
</span>{" "}
|
||||||
time is added back
|
A traditional fixed countdown (such as 60 seconds per pick). It
|
||||||
</span>{" "}
|
is a straightforward and consistent pace.
|
||||||
to your bank. Any time you don't use carries over to your next
|
</li>
|
||||||
pick.
|
<li>
|
||||||
</p>
|
<span className="font-semibold text-foreground">
|
||||||
<p className="text-muted-foreground">
|
Chess Clock (Fischer Increment):
|
||||||
Pick quickly early on and you'll build up a bigger bank for the
|
</span>{" "}
|
||||||
later rounds when decisions get harder. Deliberate too long and
|
This system uses a Time Bank and an Increment. You start with a
|
||||||
your bank shrinks. It rewards decisiveness without penalizing you
|
pool of time (for example, 2 minutes). Each time you make a
|
||||||
for taking a moment when you really need it.
|
pick, a set bonus like 15 seconds is added back to your bank.
|
||||||
</p>
|
This allows you to pick quickly in the early rounds to save up
|
||||||
<div className="bg-muted rounded-md p-3 text-sm text-muted-foreground">
|
time for more difficult decisions later on.
|
||||||
<span className="font-semibold text-foreground">Example:</span>{" "}
|
</li>
|
||||||
On Standard speed, you start with 2 minutes. You make your first
|
</ul>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draft Queue & Autodraft */}
|
{/* Flexible Pacing */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 mb-6">
|
||||||
<h3 className="text-lg font-semibold">Draft Queue & Autodraft</h3>
|
<h3 className="text-lg font-semibold">Flexible Pacing</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Before and during the draft you can build a{" "}
|
These mechanics can be applied to any draft speed. Whether you
|
||||||
<span className="font-semibold text-foreground">draft queue</span>{" "}
|
want to finish in an hour or over the course of a week, the system
|
||||||
— an ordered list of picks you want to make. When it's your turn,
|
scales to your needs:
|
||||||
you can pick manually or let your queue guide you.
|
|
||||||
</p>
|
</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">
|
<p className="text-muted-foreground">
|
||||||
If your time bank runs out,{" "}
|
To keep the draft moving regardless of the pace, you can use the{" "}
|
||||||
<span className="font-semibold text-foreground">autodraft</span>{" "}
|
<span className="font-semibold text-foreground">Draft Queue</span>{" "}
|
||||||
kicks in automatically. It'll grab the first available pick from
|
to pre-rank your targets. If your clock expires, the system
|
||||||
your queue. If your queue is empty or everything in it has
|
automatically selects the highest available player from your
|
||||||
already been taken, it'll grab from the top of the available player pool.
|
queue. If your queue is empty, the system will select the
|
||||||
Keep your queue updated and you'll never get stuck with a random
|
highest-ranked athlete remaining in the pool to ensure the draft
|
||||||
pick.
|
never stalls.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -300,56 +324,21 @@ export default function HowToPlay() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</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 */}
|
{/* Links */}
|
||||||
<section className="flex flex-col sm:flex-row gap-4 items-center justify-between pt-2">
|
<section className="flex flex-col sm:flex-row gap-4 items-center justify-between pt-2">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Want the full breakdown? Read the{" "}
|
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
|
official rules
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Button asChild size="lg">
|
||||||
to="/leagues/new"
|
<Link to="/leagues/new">Create a League</Link>
|
||||||
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"
|
</Button>
|
||||||
>
|
|
||||||
Create a League
|
|
||||||
</Link>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useLoaderData } from "react-router";
|
import { useLoaderData, Link } from "react-router";
|
||||||
import { eq, asc, and } from "drizzle-orm";
|
import { eq, asc, and, inArray } from "drizzle-orm";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -7,6 +7,10 @@ import { DraftGrid } from "~/components/DraftGrid";
|
||||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { buildOwnerMap } from "~/lib/owner-map";
|
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";
|
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
@ -92,6 +96,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
team: schema.teams,
|
team: schema.teams,
|
||||||
participant: schema.participants,
|
participant: schema.participants,
|
||||||
sport: schema.sports,
|
sport: schema.sports,
|
||||||
|
scoringPattern: schema.sportsSeasons.scoringPattern,
|
||||||
})
|
})
|
||||||
.from(schema.draftPicks)
|
.from(schema.draftPicks)
|
||||||
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
.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 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 {
|
return {
|
||||||
season,
|
season,
|
||||||
draftSlots,
|
draftSlots,
|
||||||
draftPicks,
|
draftPicks,
|
||||||
ownerMap,
|
ownerMap,
|
||||||
|
coronaStates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DraftBoard() {
|
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 { isConnected, on, off } = useDraftSocket(season.id);
|
||||||
const [picks, setPicks] = useState(initialPicks);
|
const [picks, setPicks] = useState(initialPicks);
|
||||||
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
||||||
|
|
@ -157,36 +259,46 @@ export default function DraftBoard() {
|
||||||
draftGrid.push(roundPicks);
|
draftGrid.push(roundPicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDraftActive = season.status === "draft";
|
||||||
|
const currentRound = totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b bg-card sticky top-0 z-10">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold">
|
<Link to="/">
|
||||||
{season.league.name} - {season.year} Draft Board
|
<img src="/logomark.svg" alt="Brackt" className="h-8" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{season.league.name} Draft Board
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
{season.status === "draft" && (
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
{isDraftActive && (
|
||||||
<div
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
className={`w-3 h-3 rounded-full ${
|
<span>Round {currentRound}</span>
|
||||||
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
<span>Pick {currentPick}</span>
|
||||||
}`}
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<div
|
||||||
<span className="text-sm font-medium">
|
className={`w-2.5 h-2.5 rounded-full ${
|
||||||
{isConnected ? "Connected" : "Disconnected"}
|
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
||||||
</span>
|
}`}
|
||||||
</div>
|
/>
|
||||||
)}
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -198,6 +310,9 @@ export default function DraftBoard() {
|
||||||
draftGrid={draftGrid}
|
draftGrid={draftGrid}
|
||||||
currentPick={currentPick}
|
currentPick={currentPick}
|
||||||
ownerMap={ownerMap}
|
ownerMap={ownerMap}
|
||||||
|
coronaStates={coronaStates}
|
||||||
|
seasonStatus={season.status}
|
||||||
|
draftPaused={season.draftPaused}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { getTeamQueue } from "~/models/draft-queue";
|
import { getTeamQueue } from "~/models/draft-queue";
|
||||||
|
|
@ -18,6 +18,7 @@ import { DraftSummaryView } from "~/components/draft/DraftSummaryView";
|
||||||
import { QueueSection } from "~/components/draft/QueueSection";
|
import { QueueSection } from "~/components/draft/QueueSection";
|
||||||
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
||||||
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
||||||
|
import { RecentPicksFeed } from "~/components/draft/RecentPicksFeed";
|
||||||
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
||||||
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
||||||
import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay";
|
import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay";
|
||||||
|
|
@ -27,7 +28,7 @@ import { getTeamForPick } from "~/lib/draft-order";
|
||||||
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
||||||
import { useMediaQuery } from "~/hooks/useMediaQuery";
|
import { useMediaQuery } from "~/hooks/useMediaQuery";
|
||||||
import { NotificationSettings } from "~/components/NotificationSettings";
|
import { NotificationSettings } from "~/components/NotificationSettings";
|
||||||
import { AutodraftSettings, getAutodraftLabel } from "~/components/AutodraftSettings";
|
import { AutodraftSettings, getAutodraftLabel, AutodraftBadgeWithPopover } from "~/components/AutodraftSettings";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
||||||
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
|
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
|
||||||
|
|
@ -46,7 +47,7 @@ type AutodraftStatusEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOBILE_TABS_BASE = [
|
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: "board" as const, label: "Board", Icon: LayoutGrid },
|
||||||
{ id: "teams" as const, label: "Teams", Icon: ListChecks },
|
{ id: "teams" as const, label: "Teams", Icon: ListChecks },
|
||||||
{ id: "controls" as const, label: "Controls", Icon: Settings },
|
{ id: "controls" as const, label: "Controls", Icon: Settings },
|
||||||
|
|
@ -539,11 +540,12 @@ export default function DraftRoom() {
|
||||||
const stored = localStorage.getItem("draftSidebarCollapsed");
|
const stored = localStorage.getItem("draftSidebarCollapsed");
|
||||||
return stored ? JSON.parse(stored) : false;
|
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">(
|
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">(
|
||||||
!userTeam && isCommissioner ? "board" : "available"
|
!userTeam && isCommissioner ? "board" : "available"
|
||||||
);
|
);
|
||||||
const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters");
|
const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters");
|
||||||
|
const [boardSubTab, setBoardSubTab] = useState<"board" | "pick-list">("board");
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
|
|
||||||
// Track autodraft status for all teams
|
// Track autodraft status for all teams
|
||||||
|
|
@ -643,6 +645,17 @@ export default function DraftRoom() {
|
||||||
);
|
);
|
||||||
}, [availableParticipants]);
|
}, [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
|
// Calculate draft eligibility for current user's team
|
||||||
const eligibility = useMemo(() => {
|
const eligibility = useMemo(() => {
|
||||||
if (!userTeam) return null;
|
if (!userTeam) return null;
|
||||||
|
|
@ -1384,6 +1397,7 @@ export default function DraftRoom() {
|
||||||
const draftGrid = useMemo(() => {
|
const draftGrid = useMemo(() => {
|
||||||
const teamCount = draftSlots.length;
|
const teamCount = draftSlots.length;
|
||||||
const rounds = season.draftRounds;
|
const rounds = season.draftRounds;
|
||||||
|
const pickMap = new Map(picks.map((p) => [p.pickNumber, p]));
|
||||||
const grid: Array<
|
const grid: Array<
|
||||||
Array<{
|
Array<{
|
||||||
pickNumber: number;
|
pickNumber: number;
|
||||||
|
|
@ -1404,7 +1418,7 @@ export default function DraftRoom() {
|
||||||
const pickNumber = (round - 1) * teamCount + pickInRound;
|
const pickNumber = (round - 1) * teamCount + pickInRound;
|
||||||
const teamId = draftSlots[teamIndex]?.team.id;
|
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 });
|
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
|
||||||
}
|
}
|
||||||
|
|
@ -1491,8 +1505,26 @@ export default function DraftRoom() {
|
||||||
}
|
}
|
||||||
}, [userDraftedSportNames]);
|
}, [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,
|
participants: filteredParticipants,
|
||||||
|
participantRanks,
|
||||||
|
miniDraftGrid,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
sportFilters,
|
sportFilters,
|
||||||
hideDrafted,
|
hideDrafted,
|
||||||
|
|
@ -1513,7 +1545,7 @@ export default function DraftRoom() {
|
||||||
onMakePick: handleMakePick,
|
onMakePick: handleMakePick,
|
||||||
onAddToQueue: handleAddToQueue,
|
onAddToQueue: handleAddToQueue,
|
||||||
onRemoveFromQueue: handleRemoveFromQueue,
|
onRemoveFromQueue: handleRemoveFromQueue,
|
||||||
};
|
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue]);
|
||||||
|
|
||||||
const draftGridSectionProps = {
|
const draftGridSectionProps = {
|
||||||
draftSlots,
|
draftSlots,
|
||||||
|
|
@ -1524,6 +1556,8 @@ export default function DraftRoom() {
|
||||||
connectedTeams,
|
connectedTeams,
|
||||||
isCommissioner,
|
isCommissioner,
|
||||||
ownerMap,
|
ownerMap,
|
||||||
|
seasonStatus: season.status,
|
||||||
|
draftPaused: isPaused,
|
||||||
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
||||||
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
|
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
|
||||||
onForceAutopick: handleForceAutopick,
|
onForceAutopick: handleForceAutopick,
|
||||||
|
|
@ -1545,6 +1579,7 @@ export default function DraftRoom() {
|
||||||
picks,
|
picks,
|
||||||
sports: seasonSportsData,
|
sports: seasonSportsData,
|
||||||
ownerMap,
|
ownerMap,
|
||||||
|
totalRounds: season.draftRounds,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutodraftUpdate = useCallback(
|
const handleAutodraftUpdate = useCallback(
|
||||||
|
|
@ -1558,13 +1593,8 @@ export default function DraftRoom() {
|
||||||
? {
|
? {
|
||||||
queue,
|
queue,
|
||||||
availableParticipants,
|
availableParticipants,
|
||||||
seasonId: season.id,
|
|
||||||
teamId: userTeam.id,
|
|
||||||
isMyTurn,
|
|
||||||
canPick,
|
canPick,
|
||||||
userAutodraft,
|
|
||||||
onRemoveFromQueue: handleRemoveFromQueue,
|
onRemoveFromQueue: handleRemoveFromQueue,
|
||||||
onAutodraftUpdate: handleAutodraftUpdate,
|
|
||||||
onReorder: handleReorderQueue,
|
onReorder: handleReorderQueue,
|
||||||
onMakePick: handleMakePick,
|
onMakePick: handleMakePick,
|
||||||
}
|
}
|
||||||
|
|
@ -1626,20 +1656,25 @@ export default function DraftRoom() {
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div>
|
<Link to="/">
|
||||||
<h1 className="text-lg md:text-3xl font-bold">
|
<img src="/logomark.svg" alt="Brackt" className="h-8" />
|
||||||
{season.league.name} - {season.year} Draft
|
</Link>
|
||||||
</h1>
|
<h1 className="text-lg md:text-xl font-bold">
|
||||||
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
|
Draft Room
|
||||||
<span>Round: {currentRound}</span>
|
</h1>
|
||||||
<span>Pick: {currentPick}</span>
|
|
||||||
<span className="capitalize">
|
|
||||||
{season.status.replace("_", " ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<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) */}
|
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */}
|
||||||
{isCommissioner && season.status === "pre_draft" && (
|
{isCommissioner && season.status === "pre_draft" && (
|
||||||
<Button className="hidden md:flex" onClick={handleStartDraft}>Start Draft</Button>
|
<Button className="hidden md:flex" onClick={handleStartDraft}>Start Draft</Button>
|
||||||
|
|
@ -1659,16 +1694,6 @@ export default function DraftRoom() {
|
||||||
</div>
|
</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">
|
<Button variant="outline" asChild className="hidden md:flex">
|
||||||
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1705,12 +1730,51 @@ export default function DraftRoom() {
|
||||||
</div>
|
</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 */}
|
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="flex h-full overflow-hidden flex-col">
|
<div className="flex h-full overflow-hidden flex-col">
|
||||||
{mobileTab === "available" && (
|
{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" && (
|
{mobileTab === "queue" && (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
|
|
@ -1724,7 +1788,39 @@ export default function DraftRoom() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mobileTab === "board" && (
|
{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" && (
|
{mobileTab === "teams" && (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<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]">
|
<Button variant="outline" asChild className="w-full min-h-[48px]">
|
||||||
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1805,84 +1888,22 @@ export default function DraftRoom() {
|
||||||
onCollapsedChange={setSidebarCollapsed}
|
onCollapsedChange={setSidebarCollapsed}
|
||||||
queueSection={<QueueSection {...queueSectionProps} />}
|
queueSection={<QueueSection {...queueSectionProps} />}
|
||||||
recentPicksSection={<SidebarRecentPicks picks={picks} />}
|
recentPicksSection={<SidebarRecentPicks picks={picks} />}
|
||||||
settingsSection={
|
|
||||||
<NotificationSettings
|
|
||||||
enabled={notificationsEnabled}
|
|
||||||
onEnabledChange={setNotificationsEnabled}
|
|
||||||
mode={notificationsMode}
|
|
||||||
onModeChange={setNotificationsMode}
|
|
||||||
permissionState={notificationsPermission}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<Tabs
|
<div className={`absolute inset-0 ${activeTab === "participants" ? "" : "hidden"}`}>
|
||||||
value={activeTab}
|
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
|
||||||
onValueChange={(value) =>
|
</div>
|
||||||
setActiveTab(value as "participants" | "board" | "rosters" | "summary")
|
<div className={`absolute inset-0 ${activeTab === "board" ? "" : "hidden"}`}>
|
||||||
}
|
<DraftGridSection {...draftGridSectionProps} />
|
||||||
className="h-full flex flex-col"
|
</div>
|
||||||
>
|
<div className={`absolute inset-0 ${activeTab === "rosters" ? "" : "hidden"}`}>
|
||||||
<div className={`mt-4 transition-all duration-300 ${
|
<TeamRosterView {...rosterViewProps} />
|
||||||
isMyTurn && season.status === "draft" && !isDraftComplete
|
</div>
|
||||||
? "bg-electric/25 border-y-2 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
|
<div className={`absolute inset-0 ${activeTab === "summary" ? "" : "hidden"}`}>
|
||||||
: ""
|
<DraftSummaryView {...summaryViewProps} />
|
||||||
}`}>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { findCurrentSeasonWithSports } from "~/models/season";
|
||||||
import { getSeasonStandings } from "~/models/standings";
|
import { getSeasonStandings } from "~/models/standings";
|
||||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
||||||
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
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 { getAuditLogForSeason } from "~/models/audit-log";
|
||||||
import type { Route } from "./+types/$leagueId";
|
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 dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
||||||
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
||||||
|
|
||||||
const participantsBySportsSeason = myTeam && season
|
const [participantsBySportsSeason, participantsWithPoints]: [
|
||||||
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
Map<string, Array<{ id: string; name: string }>>,
|
||||||
: new 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 dateFrom = new Date(dateFromStr + "T00:00:00.000Z");
|
||||||
const dateTo = new Date(dateToStr + "T23:59:59.999Z");
|
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(
|
const sportsSeasons = await Promise.all(
|
||||||
rawSportsSeasons.map(async (ss) => {
|
rawSportsSeasons.map(async (ss) => {
|
||||||
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
||||||
|
const draftedParticipantsWithPoints = participantsWithPoints.get(ss.id) ?? [];
|
||||||
const upcomingParticipantEvents =
|
const upcomingParticipantEvents =
|
||||||
draftedParticipants.length > 0
|
draftedParticipants.length > 0
|
||||||
? await getUpcomingEventsForDraftedParticipants(
|
? await getUpcomingEventsForDraftedParticipants(
|
||||||
|
|
@ -166,6 +173,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
scoringPattern: ss.scoringPattern,
|
scoringPattern: ss.scoringPattern,
|
||||||
sport: ss.sport,
|
sport: ss.sport,
|
||||||
upcomingParticipantEvents,
|
upcomingParticipantEvents,
|
||||||
|
draftedParticipantsWithPoints,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -179,6 +187,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
sportName: ss.sport.name,
|
sportName: ss.sport.name,
|
||||||
sportSeasonName: ss.name,
|
sportSeasonName: ss.name,
|
||||||
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
|
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
|
||||||
|
leagueId,
|
||||||
|
leagueName: league.name,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
|
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
|
||||||
|
|
|
||||||
|
|
@ -166,14 +166,15 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
};
|
};
|
||||||
let groupStandings: GroupStandingData[] = [];
|
let groupStandings: GroupStandingData[] = [];
|
||||||
|
|
||||||
|
let bracketTemplateId: string | null = null;
|
||||||
|
|
||||||
if (scoringPattern === "playoff_bracket") {
|
if (scoringPattern === "playoff_bracket") {
|
||||||
// Fetch playoff matches via scoring events
|
// Fetch playoff matches via scoring events
|
||||||
|
let templateId: string | undefined;
|
||||||
const events = await db.query.scoringEvents.findMany({
|
const events = await db.query.scoringEvents.findMany({
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
});
|
});
|
||||||
|
|
||||||
let templateId: string | undefined;
|
|
||||||
|
|
||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
const eventIds = events.map((e) => e.id);
|
const eventIds = events.map((e) => e.id);
|
||||||
const matches = await db.query.playoffMatches.findMany({
|
const matches = await db.query.playoffMatches.findMany({
|
||||||
|
|
@ -188,6 +189,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
playoffMatches = matches as PlayoffMatchWithRelations[];
|
playoffMatches = matches as PlayoffMatchWithRelations[];
|
||||||
|
|
||||||
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
||||||
|
bracketTemplateId = templateId ?? null;
|
||||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||||
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
||||||
|
|
||||||
|
|
@ -339,5 +341,6 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
regularSeasonStandings,
|
regularSeasonStandings,
|
||||||
participantEvs,
|
participantEvs,
|
||||||
groupStandings,
|
groupStandings,
|
||||||
|
bracketTemplateId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
|
|
||||||
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
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 { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
|
||||||
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { cn } from "~/lib/utils";
|
||||||
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `${data?.sportsSeason?.name ?? "Sport Season"} — ${data?.league?.name ?? "League"} - Brackt` }];
|
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 };
|
export { loader };
|
||||||
|
|
||||||
function getStatusBadge(status: string) {
|
type BracketView = "standings" | "playoffs" | "finished";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SportSeasonDetail({
|
export default function SportSeasonDetail({
|
||||||
loaderData,
|
loaderData,
|
||||||
|
|
@ -65,8 +40,9 @@ export default function SportSeasonDetail({
|
||||||
recentEvents,
|
recentEvents,
|
||||||
seasonIsFinalized,
|
seasonIsFinalized,
|
||||||
regularSeasonStandings,
|
regularSeasonStandings,
|
||||||
participantEvs,
|
|
||||||
groupStandings,
|
groupStandings,
|
||||||
|
bracketTemplateId,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
||||||
|
|
@ -78,14 +54,9 @@ export default function SportSeasonDetail({
|
||||||
simulatorType === "nhl_bracket" ? "nhl-divisions" :
|
simulatorType === "nhl_bracket" ? "nhl-divisions" :
|
||||||
simulatorType === "mlb_bracket" ? "mlb-divisions" :
|
simulatorType === "mlb_bracket" ? "mlb-divisions" :
|
||||||
"flat";
|
"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 =
|
const playoffSpots =
|
||||||
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
|
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
|
||||||
|
|
||||||
// Build ownership map for RegularSeasonStandings
|
|
||||||
const ownershipMap = Object.fromEntries(
|
const ownershipMap = Object.fromEntries(
|
||||||
teamOwnerships.map((o) => [
|
teamOwnerships.map((o) => [
|
||||||
o.participantId,
|
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 (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -103,7 +130,7 @@ export default function SportSeasonDetail({
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-1">
|
<h1 className="text-3xl font-bold mb-1">
|
||||||
{sportsSeason.sport.name}
|
{sportsSeason.sport.name}
|
||||||
|
|
@ -112,83 +139,68 @@ export default function SportSeasonDetail({
|
||||||
{sportsSeason.name} • {league.name} • {season.year} Season
|
{sportsSeason.name} • {league.name} • {season.year} Season
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Regular season standings — show above bracket when no bracket exists yet */}
|
{showToggle ? (
|
||||||
{hasStandings && !hasBracket && (
|
<div>
|
||||||
<div className="mb-6">
|
{view === "standings" && standingsDisplay}
|
||||||
<RegularSeasonStandings
|
{view === "playoffs" && bracketDisplay("bracket")}
|
||||||
standings={regularSeasonStandings}
|
{view === "finished" && bracketDisplay("rankings")}
|
||||||
teamOwnerships={ownershipMap}
|
|
||||||
userParticipantIds={userParticipantIds}
|
|
||||||
showOtLosses={showOtLosses}
|
|
||||||
participantEvs={participantEvs}
|
|
||||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
|
||||||
playoffSpots={playoffSpots}
|
|
||||||
/>
|
|
||||||
</div>
|
</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) */}
|
{/* Event schedule — hidden for bracket sports */}
|
||||||
{scoringPattern !== "playoff_bracket" &&
|
{scoringPattern !== "playoff_bracket" &&
|
||||||
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<EventSchedule
|
<EventSchedule
|
||||||
upcomingEvents={upcomingEvents}
|
upcomingEvents={upcomingEvents}
|
||||||
recentEvents={recentEvents}
|
recentEvents={recentEvents}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */}
|
{/* Group stage standings */}
|
||||||
{groupStandings.length > 0 && (
|
{groupStandings.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
|
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
|
{/* Bracket — hidden when standings exist but bracket is empty */}
|
||||||
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
|
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && bracketDisplay("bracket")}
|
||||||
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}
|
|
||||||
/>}
|
|
||||||
|
|
||||||
{/* Regular season standings — show below bracket once bracket exists */}
|
{/* Regular season standings below bracket once bracket exists */}
|
||||||
{hasStandings && hasBracket && (
|
{hasStandings && hasBracket && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">{standingsDisplay}</div>
|
||||||
<RegularSeasonStandings
|
)}
|
||||||
standings={regularSeasonStandings}
|
</>
|
||||||
teamOwnerships={ownershipMap}
|
|
||||||
userParticipantIds={userParticipantIds}
|
|
||||||
showOtLosses={showOtLosses}
|
|
||||||
participantEvs={participantEvs}
|
|
||||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
|
||||||
playoffSpots={playoffSpots}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,15 @@ import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { Button } from "~/components/ui/button";
|
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 { logger } from "~/lib/logger";
|
||||||
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
|
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
|
||||||
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
||||||
|
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
|
||||||
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
||||||
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user";
|
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";
|
import type { Route } from "./+types/$leagueId.standings.$seasonId";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
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
|
// 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([
|
await Promise.all([
|
||||||
getSevenDayStandingsChange(seasonId, db),
|
getSevenDayStandingsChange(seasonId, db),
|
||||||
db.query.teams.findMany({
|
db.query.teams.findMany({
|
||||||
|
|
@ -83,6 +85,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
getSeasonPointProgression(seasonId, db),
|
getSeasonPointProgression(seasonId, db),
|
||||||
isSeasonComplete(seasonId, db),
|
isSeasonComplete(seasonId, db),
|
||||||
getSeasonCompletionPercentage(seasonId, db),
|
getSeasonCompletionPercentage(seasonId, db),
|
||||||
|
getRecentTeamScoreEvents(seasonId, 10, db),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build teamId -> ownerName map
|
// Build teamId -> ownerName map
|
||||||
|
|
@ -105,6 +108,12 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
|
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Enrich recentScoreEvents with owner names
|
||||||
|
const enrichedScoreEvents = recentScoreEvents.map((event) => ({
|
||||||
|
...event,
|
||||||
|
ownerName: ownerNameByTeamId.get(event.teamId) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
|
|
@ -112,6 +121,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
progressionData,
|
progressionData,
|
||||||
seasonComplete,
|
seasonComplete,
|
||||||
completionPercentage,
|
completionPercentage,
|
||||||
|
recentScoreEvents: enrichedScoreEvents,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error loading standings:", error);
|
logger.error("Error loading standings:", error);
|
||||||
|
|
@ -124,7 +134,20 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeagueStandings() {
|
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 (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
|
@ -157,75 +180,26 @@ export default function LeagueStandings() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Standings Card */}
|
{/* Two-column layout */}
|
||||||
<Card>
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<CardHeader>
|
{/* Left: Standings + Point Progression (2/3 width) */}
|
||||||
<CardTitle>
|
<div className="md:col-span-2 space-y-6">
|
||||||
{seasonComplete ? "Final Standings" : "Current Standings"}
|
<StandingsPreview
|
||||||
</CardTitle>
|
entries={previewEntries}
|
||||||
</CardHeader>
|
description={seasonComplete ? "Final Standings" : "Current Standings"}
|
||||||
<CardContent>
|
/>
|
||||||
{standings.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
{progressionData.chartData.length > 0 && (
|
||||||
<p className="text-lg">No standings data yet.</p>
|
<PointProgressionChart
|
||||||
<p className="text-sm mt-2">
|
chartData={progressionData.chartData}
|
||||||
Standings will appear once participants have results.
|
teams={progressionData.teams}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<StandingsTable
|
|
||||||
standings={standings}
|
|
||||||
leagueId={league.id}
|
|
||||||
seasonId={season.id}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Point Progression Chart */}
|
|
||||||
{progressionData.chartData.length > 0 && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<PointProgressionChart
|
|
||||||
chartData={progressionData.chartData}
|
|
||||||
teams={progressionData.teams}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info Card */}
|
{/* Right: Recent Scores (1/3 width) */}
|
||||||
<Card className="mt-6">
|
<RecentScoresCard events={recentScoreEvents} />
|
||||||
<CardContent className="pt-6">
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router";
|
import { Link, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { format } from "date-fns";
|
|
||||||
import type { Route } from "./+types/$leagueId";
|
import type { Route } from "./+types/$leagueId";
|
||||||
|
|
||||||
|
import { Link2 } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
Card,
|
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
|
||||||
CardContent,
|
import { TeamsPanel } from "~/components/league/TeamsPanel";
|
||||||
CardDescription,
|
import { DraftInfoCard } from "~/components/league/DraftInfoCard";
|
||||||
CardHeader,
|
import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
|
||||||
CardTitle,
|
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
|
||||||
} 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 { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
|
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
|
||||||
|
|
@ -36,7 +33,6 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
commissionerMap,
|
commissionerMap,
|
||||||
availableTeamCount,
|
availableTeamCount,
|
||||||
sportsCount,
|
sportsCount,
|
||||||
teamsWithOwners,
|
|
||||||
isDraftOrderSet,
|
isDraftOrderSet,
|
||||||
draftSlots,
|
draftSlots,
|
||||||
sportsSeasons,
|
sportsSeasons,
|
||||||
|
|
@ -47,6 +43,10 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
const myTeam = teams.find((t) => t.ownerId === currentUserId);
|
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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -97,6 +97,21 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
return rankA - rankB;
|
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,
|
// Pre-compute sorted sports seasons: active → upcoming → completed,
|
||||||
// season_standings last within active, then alphabetical by sport name
|
// season_standings last within active, then alphabetical by sport name
|
||||||
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
|
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
|
||||||
|
|
@ -117,18 +132,28 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
<h1 className="text-4xl font-bold">{league.name}</h1>
|
<div>
|
||||||
<div className="flex gap-2">
|
<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 && (
|
{myTeam && (
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link to={`/teams/${myTeam.id}/settings`}>
|
<Link to={`/teams/${myTeam.id}/settings`}>
|
||||||
Team Settings
|
Team Settings
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isUserCommissioner && (
|
{isUserCommissioner && (
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link to={`/leagues/${league.id}/settings`}>
|
<Link to={`/leagues/${league.id}/settings`}>
|
||||||
League Settings
|
League Settings
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -136,14 +161,6 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{season && (
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{season.year} Season •{" "}
|
|
||||||
<span className="capitalize">
|
|
||||||
{season.status.replace("_", " ")}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{season?.status === "draft" && (
|
{season?.status === "draft" && (
|
||||||
|
|
@ -169,94 +186,68 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
{/* Left Column - 2/3 width on desktop */}
|
{/* Left Column - 2/3 width on desktop */}
|
||||||
<div className="md:col-span-2 space-y-6">
|
<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 */}
|
{/* Standings Panel - active/completed seasons */}
|
||||||
{season && isActiveOrCompleted && (
|
{season && isActiveOrCompleted && (
|
||||||
<Card>
|
<StandingsPreview
|
||||||
<CardHeader>
|
entries={standingsEntries}
|
||||||
<div className="flex items-center justify-between">
|
description={season.status === "completed" ? "Final standings" : undefined}
|
||||||
<div>
|
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sports Seasons Section */}
|
{/* Sports Seasons Section */}
|
||||||
{season && sortedSportsSeasons.length > 0 && (
|
{season && sortedSportsSeasons.length > 0 && (
|
||||||
<Card>
|
<SportsSeasonsSummary
|
||||||
<CardHeader>
|
leagueId={league.id}
|
||||||
<CardTitle>Sports Seasons</CardTitle>
|
sportsSeasons={sortedSportsSeasons.map((ss) => ({
|
||||||
<CardDescription>
|
id: ss.id,
|
||||||
{sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
|
sportName: ss.sport.name,
|
||||||
</CardDescription>
|
seasonName: ss.name,
|
||||||
</CardHeader>
|
status: ss.status,
|
||||||
<CardContent>
|
scoringPattern: ss.scoringPattern,
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
upcomingParticipantEvents: ss.upcomingParticipantEvents,
|
||||||
{sortedSportsSeasons.map((sportSeason) => (
|
draftedParticipants: ss.draftedParticipantsWithPoints,
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && (
|
</div>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
{/* Right Column - 1/3 width on desktop */}
|
||||||
<CardTitle>Invite Link</CardTitle>
|
<div className="space-y-14">
|
||||||
<CardDescription>
|
{/* Teams - shown above invite link when league is full */}
|
||||||
Share this link to invite people to join your league (
|
{season && isDraftOrPreDraft && availableTeamCount === 0 && (
|
||||||
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""}{" "}
|
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
|
||||||
available)
|
)}
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
{/* Invite Link - bare, pre_draft commissioner only */}
|
||||||
<CardContent className="space-y-3">
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -276,295 +267,49 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Anyone with this link can join your league
|
Anyone with this link can join your league
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Draft Order card - shown when order is set, pre_draft/draft */}
|
{/* Teams - shown below invite link when league is not full */}
|
||||||
{season && isDraftOrPreDraft && draftSlots.length > 0 && (
|
{season && isDraftOrPreDraft && availableTeamCount > 0 && (
|
||||||
<Card>
|
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
|
||||||
<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 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 && (
|
{upcomingCalendarEvents.length > 0 && (
|
||||||
<UpcomingCalendarPanel
|
<UpcomingEventsCard
|
||||||
events={upcomingCalendarEvents}
|
events={upcomingCalendarEvents}
|
||||||
showLeague={false}
|
title="Upcoming Events"
|
||||||
limit={6}
|
limit={6}
|
||||||
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
|
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>
|
{/* Draft historical view - active/completed only */}
|
||||||
<CardHeader>
|
{season && isActiveOrCompleted && (
|
||||||
<CardTitle>Commissioners</CardTitle>
|
<DraftInfoCard
|
||||||
<CardDescription>
|
draftRounds={season.draftRounds}
|
||||||
{commissioners.length} commissioner
|
draftTimerMode={season.draftTimerMode}
|
||||||
{commissioners.length !== 1 ? "s" : ""}
|
draftInitialTime={season.draftInitialTime}
|
||||||
</CardDescription>
|
draftIncrementTime={season.draftIncrementTime}
|
||||||
</CardHeader>
|
sportsCount={sportsCount}
|
||||||
<CardContent>
|
isDraftOrderSet={isDraftOrderSet}
|
||||||
<div className="space-y-2">
|
isDraftOrPreDraft={false}
|
||||||
{commissioners.map((commissioner) => {
|
draftDateTime={season.draftDateTime}
|
||||||
const commissionerName = commissionerMap[commissioner.userId];
|
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
||||||
const hasTeam = teams.some(
|
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
||||||
(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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CommissionersPanel
|
||||||
|
commissioners={commissioners}
|
||||||
|
commissionerMap={commissionerMap}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
teams={teams}
|
||||||
|
activityEntries={recentActivity.entries}
|
||||||
|
auditLogUrl={`/leagues/${league.id}/audit-log`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
771
app/routes/privacy-policy.tsx
Normal file
771
app/routes/privacy-policy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,8 @@ export default function Rules() {
|
||||||
Points are awarded based on where a drafted team or individual
|
Points are awarded based on where a drafted team or individual
|
||||||
finishes in their sport. The default point values are listed
|
finishes in their sport. The default point values are listed
|
||||||
below. Commissioners may adjust these values before the draft
|
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>
|
</p>
|
||||||
<div className="bg-muted rounded-lg overflow-hidden">
|
<div className="bg-muted rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<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
|
each new round — if you pick last in round 1, you pick first in
|
||||||
round 2, and so on.
|
round 2, and so on.
|
||||||
</p>
|
</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>
|
<p>
|
||||||
If a participant's time bank runs out, the system will
|
If a participant's time bank runs out, the system will
|
||||||
automatically draft on their behalf. Autodraft picks from the
|
automatically draft on their behalf. Autodraft picks from the
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
|
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
|
// Users table - synced from Clerk
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
|
|
@ -1295,3 +1295,55 @@ export const commissionerAuditLogRelations = relations(commissionerAuditLog, ({
|
||||||
references: [leagues.id],
|
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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
47
docs/agents/architecture.md
Normal file
47
docs/agents/architecture.md
Normal 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
7
docs/agents/auth.md
Normal 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
35
docs/agents/database.md
Normal 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`.
|
||||||
34
docs/agents/domain-models.md
Normal file
34
docs/agents/domain-models.md
Normal 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
32
docs/agents/routing.md
Normal 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
30
docs/agents/testing.md
Normal 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
|
||||||
|
```
|
||||||
30
drizzle/0077_lively_wolfpack.sql
Normal file
30
drizzle/0077_lively_wolfpack.sql
Normal 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");
|
||||||
1
drizzle/0078_striped_rick_jones.sql
Normal file
1
drizzle/0078_striped_rick_jones.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "team_score_events" ADD COLUMN "participant_ids" text[];
|
||||||
4
drizzle/0079_wide_trauma.sql
Normal file
4
drizzle/0079_wide_trauma.sql
Normal 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;
|
||||||
5
drizzle/0080_chemical_gorilla_man.sql
Normal file
5
drizzle/0080_chemical_gorilla_man.sql
Normal 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 $$;
|
||||||
4751
drizzle/meta/0077_snapshot.json
Normal file
4751
drizzle/meta/0077_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4757
drizzle/meta/0078_snapshot.json
Normal file
4757
drizzle/meta/0078_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4792
drizzle/meta/0079_snapshot.json
Normal file
4792
drizzle/meta/0079_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4805
drizzle/meta/0080_snapshot.json
Normal file
4805
drizzle/meta/0080_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -540,6 +540,34 @@
|
||||||
"when": 1776226761564,
|
"when": 1776226761564,
|
||||||
"tag": "0076_heavy_zemo",
|
"tag": "0076_heavy_zemo",
|
||||||
"breakpoints": true
|
"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
36
package-lock.json
generated
|
|
@ -29,6 +29,7 @@
|
||||||
"@react-router/express": "^7.7.1",
|
"@react-router/express": "^7.7.1",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
"@sentry/react-router": "^10.43.0",
|
"@sentry/react-router": "^10.43.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/nprogress": "^0.2.3",
|
"@types/nprogress": "^0.2.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -37,7 +38,7 @@
|
||||||
"drizzle-orm": "~0.36.3",
|
"drizzle-orm": "~0.36.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^1.8.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
|
@ -6491,6 +6492,33 @@
|
||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"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": {
|
"node_modules/@testing-library/cypress": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz",
|
||||||
|
|
@ -12517,9 +12545,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.545.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
|
||||||
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
|
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
"@react-router/express": "^7.7.1",
|
"@react-router/express": "^7.7.1",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
"@sentry/react-router": "^10.43.0",
|
"@sentry/react-router": "^10.43.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/nprogress": "^0.2.3",
|
"@types/nprogress": "^0.2.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -59,7 +60,7 @@
|
||||||
"drizzle-orm": "~0.36.3",
|
"drizzle-orm": "~0.36.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^1.8.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue