diff --git a/.storybook/preview.ts b/.storybook/preview.tsx similarity index 56% rename from .storybook/preview.ts rename to .storybook/preview.tsx index 60a654d..0f99854 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.tsx @@ -1,9 +1,18 @@ +import React from 'react'; import { withRouter } from 'storybook-addon-remix-react-router'; -import type { Preview } from '@storybook/react-vite' +import type { Preview, Decorator } from '@storybook/react-vite' import '../app/app.css' +import { BracktGradients } from '../app/components/ui/BracktGradients'; + +const withBracktGradients: Decorator = (Story) => ( + <> + + + +); const preview: Preview = { - decorators: [withRouter], // This wraps all stories in a Router context + decorators: [withRouter, withBracktGradients], parameters: { controls: { matchers: { @@ -21,4 +30,4 @@ const preview: Preview = { }, }; -export default preview; \ No newline at end of file +export default preview; diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b0fa0f --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index acec9ca..918dca8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,343 +1,34 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Brackt.com is a fantasy sports drafting platform (React Router 7 + SSR, Socket.IO, PostgreSQL/DrizzleORM, Clerk auth). -## Project Overview - -Brackt.com is a fantasy sports drafting platform built with React Router 7, featuring real-time draft functionality with Socket.IO, PostgreSQL database with DrizzleORM, and Clerk authentication. - -## Tech Stack - -- **Framework**: React Router 7 with SSR -- **Runtime**: Node.js with Express server -- **Database**: PostgreSQL with DrizzleORM -- **Auth**: Clerk (@clerk/react-router) -- **Real-time**: Socket.IO (server/socket.ts) -- **Styling**: TailwindCSS + ShadCN UI components -- **Testing**: Vitest (unit/component) + Cypress (E2E) -- **TypeScript**: Strict mode enabled - -## Development Commands +## Commands ```bash -# Development (HMR enabled) -npm run dev - -# Build -npm run build # Build both Remix and server -npm run build:remix # Build React Router app only -npm run build:server # Build Express server only - -# Database -npm run db:generate # Generate Drizzle migrations -npm run db:migrate # Run migrations - -# Testing -npm test # Unit tests (watch mode) -npm run test:run # Unit tests (run once) -npm run test:ui # Unit tests with UI -npm run test:coverage # Coverage report -npm run test:e2e # Cypress interactive -npm run test:e2e:headless # Cypress headless -npm run test:all # Run all tests - -# Type checking -npm run typecheck # Check all TypeScript files - -# Production -npm start # Start production server (requires build) -npm run start:production # Run migrations + start server +npm run dev # development with HMR +npm run typecheck # TypeScript check +npm run db:generate # generate Drizzle migration +npm run db:migrate # apply migrations +npm run test:run # unit tests (once) +npm run test:e2e:headless # Cypress E2E +npm run test:all # everything ``` -## Architecture Overview +## Critical Rules -### Server Architecture +**Routes**: Every new route file must also be registered in `app/routes.ts` — React Router 7 does not auto-discover files. Forgetting this causes a silent 404. See [routing guide](docs/agents/routing.md). -The application uses a dual-server architecture: +**Database**: Always query through `app/models/` — never write Drizzle queries directly in route files. -1. **server.ts** - Main entry point that creates the HTTP server and initializes Socket.IO -2. **server/app.ts** - Express app with React Router request handler and database context -3. **server/socket.ts** - Socket.IO server for real-time draft updates -4. **server/timer.ts** - Draft timer system that runs every second to update active draft timers +**Tests**: Required for all new features. Do not consider a feature complete until tests pass. See [testing guide](docs/agents/testing.md). -**Important**: The timer system (`server/timer.ts`) must be started for draft functionality. It uses a dedicated database connection and setInterval to manage all active draft timers. +**Drizzle migrations**: Never manually create migration SQL or edit `_journal.json`. Always use `npm run db:generate`. See [database guide](docs/agents/database.md). -### Database Layer - -- **Schema**: `database/schema.ts` - All Drizzle table definitions -- **Context**: Database connection provided via AsyncLocalStorage context (`database/context.ts`) -- **Models**: `app/models/` - Business logic and database queries organized by domain - - Each model exports typed functions for CRUD operations - - Models use Drizzle relations for joins - - Example: `app/models/league.ts`, `app/models/season.ts`, `app/models/draft-pick.ts` - -### Frontend Architecture - -- **Routes**: Defined in `app/routes.ts` using React Router config syntax - - **IMPORTANT**: React Router 7 requires ALL routes to be explicitly registered in `app/routes.ts` - - **When creating a new route file, you MUST also add it to `app/routes.ts`** - - Example: If you create `app/routes/admin.sports-seasons.$id.new-feature.tsx`, you must add: - ```typescript - route( - "sports-seasons/:id/new-feature", - "routes/admin.sports-seasons.$id.new-feature.tsx" - ), - ``` -- **File-based routing**: Route files in `app/routes/` map to URL patterns - - `$param` = dynamic segment (e.g., `$leagueId.tsx`) - - `_index.tsx` = index route - - `.` separator for nested routes (e.g., `leagues/$leagueId.settings.tsx`) -- **Loaders/Actions**: Use React Router's loader/action pattern for data fetching and mutations -- **Components**: Organized in `app/components/` with UI components in `app/components/ui/` - -### Real-time Features - -Socket.IO is used for live draft updates: - -1. **Join Draft**: Clients join a room for their season (`join-draft` event) -2. **Timer Updates**: Server broadcasts `timer-update` every second during active drafts -3. **Pick Made**: Broadcasts `pick-made` when a participant is drafted -4. **Autodraft**: Broadcasts `autodraft-updated` when teams enable/disable autodraft -5. **Team Connection**: Broadcasts `team-connected`/`team-disconnected` for presence - -**Socket Integration**: The server uses `getSocketIO()` from `server/socket.ts` to emit events. Clients use the `useDraftSocket()` hook (`app/hooks/useDraftSocket.ts`) to listen for events. - -## Key Domain Models - -### Fantasy Draft System - -1. **League** - Container for multiple seasons, has commissioners -2. **Season** - Single year/iteration of a league with specific settings - - Status: `pre_draft` → `draft` → `active` → `completed` - - Stores draft settings (rounds, timer settings, etc.) -3. **Team** - Belongs to a season, owned by a user -4. **Draft Slot** - Defines draft order for teams in a season -5. **Draft Pick** - Record of each pick made during draft -6. **Draft Queue** - Pre-ordered list of participants a team wants to draft -7. **Draft Timer** - Active timer for current team on the clock -8. **Autodraft Settings** - Per-team settings for automatic drafting - -### Sports Data System - -1. **Sport** - Base sport (e.g., NFL, NBA) with type (team/individual) -2. **Sports Season** - Specific season of a sport (e.g., "2024 NFL Season") -3. **Participant** - Individual athlete or team that can be drafted -4. **Season Template** - Reusable configuration for league seasons -5. **Season Sport** - Links a fantasy season to sports it covers -6. **Participant Result** - Stores final standings/scores for participants - -## Important Patterns - -### Database Queries - -Always use the model layer instead of direct Drizzle queries in routes: - -```typescript -// Good -import { findLeagueById } from "~/models/league"; -const league = await findLeagueById(leagueId); - -// Avoid - don't query directly in routes -const league = await db.query.leagues.findFirst({...}); -``` - -### Authentication - -- User data synced from Clerk via webhook (`routes/api/webhooks/clerk.ts`) -- Check `clerkId` for ownership validation -- Admin checks via `isAdmin` boolean on users table - -### Scoring Event Dates - -Game/event dates are stored in **two different places** depending on the event type, and any date-based query must account for both: - -1. **`scoringEvents.eventDate`** (`date` column, `YYYY-MM-DD` string) — set directly on the event. Used for non-bracket events (majors, final standings) and sometimes for bracket events when the admin sets a single date for the whole round. - -2. **`playoffMatchGames.scheduledAt`** (`timestamp` column) — set on individual games within a bracket match. Bracket events (e.g. a playoff series) often have `eventDate = null` on the scoring event itself, with each game's exact time stored here instead. - -**Pattern for date-range queries:** Use a two-step approach: -1. `selectDistinct` event IDs via left joins through `playoffMatches → playoffMatchGames`, filtering with `eventDate IN [dates] OR (scheduledAt >= dayStart AND scheduledAt <= dayEnd)` -2. `findMany` on the matched IDs to load full event data with relations - -See `getEventsForDates` and `getUpcomingEventsForDraftedParticipants` in `app/models/scoring-event.ts` for the reference implementation. All timestamp bounds use UTC (`T00:00:00.000Z` / `T23:59:59.999Z`). - -### Draft Logic - -The draft system implements snake draft: -- Draft slots define the base order (1, 2, 3, ...) -- Each round alternates: odd rounds go 1→N, even rounds go N→1 -- Current pick determined by `currentPickNumber` on season -- Timer system in `server/timer.ts` handles autopicks when time expires - -### Testing Strategy - -- **Unit tests**: Pure functions and utilities in `__tests__/` directories -- **Component tests**: React components using React Testing Library -- **E2E tests**: Critical user flows in `cypress/e2e/` -- **Fixtures**: Reusable test data in `app/test/fixtures/` -- See `TESTING.md` for comprehensive testing guide - -**IMPORTANT: Tests are required for new features.** When adding any new feature, you must include appropriate tests: -- New model functions → unit tests in a co-located `__tests__/` directory -- New route loaders/actions → unit or integration tests for the logic -- New utility functions → unit tests -- New components with non-trivial logic → component tests -- Critical user flows → Cypress E2E tests - -Do not consider a feature complete until tests are written and passing. - -## Common Workflows - -### Adding a New Route - -**CRITICAL**: Both steps 1 and 2 are required - the route will not work if you skip step 1! - -1. **FIRST**: Add route config to `app/routes.ts` - - Example: `route("admin/sports-seasons/:id/new-page", "routes/admin.sports-seasons.$id.new-page.tsx")` - - Nested admin routes go inside the `route("admin", ...)` array -2. **THEN**: Create file in `app/routes/` with corresponding name -3. Export loader/action if needed -4. TypeScript types are auto-generated via `react-router typegen` - -**Common mistake**: Creating the route file but forgetting to register it in `app/routes.ts` - this will result in a 404! - -### Database Changes - -1. Update `database/schema.ts` with new tables/columns -2. Run `npm run db:generate` to create migration -3. **Verify** the generated migration SQL in `drizzle/` is correct -4. **Verify** a corresponding snapshot was created in `drizzle/meta/` (e.g., `0070_snapshot.json`) -5. Run `npm run db:migrate` to apply migration -6. Add model functions in `app/models/` for new queries -7. Update TypeScript types if needed - -**Drizzle Migration Rules (CRITICAL):** -- **NEVER** manually create migration SQL files or journal entries. Always use `drizzle-kit generate` or `drizzle-kit generate --custom` so that snapshot files are created automatically. -- **NEVER** edit `drizzle/meta/_journal.json` by hand. The journal and snapshots must stay in sync — drizzle-kit manages both. -- Every migration **must** have a matching snapshot in `drizzle/meta/`. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed" errors, which blocks all future migration generation. -- Snapshot JSON must only contain PostgreSQL-valid fields. Do not add `"autoincrement"` to column definitions — PostgreSQL uses sequences/serial instead, and invalid fields cause Zod validation failures. -- If `drizzle-kit generate` prompts for column renames interactively, use `drizzle-kit generate --custom` instead and write the migration SQL by hand (include data backfill logic as needed). -- After generating, run `drizzle-kit generate` again to confirm "No schema changes" — this validates the snapshot chain is healthy. -- For destructive migrations (dropping columns, renaming), make the SQL idempotent with `IF EXISTS`/`IF NOT EXISTS` guards where possible. - -### Adding Socket.IO Events - -1. Define event types in `server/socket.d.ts` interfaces -2. Add event handler in `server/socket.ts` -3. Emit events from server code using `getSocketIO().to(room).emit()` -4. Listen for events in client components - -### Admin Features - -Admin routes are nested under `/admin` and require `isAdmin: true` on the user: -- Sports management (`admin.sports.*`) -- Sports seasons (`admin.sports-seasons.*`) -- Season templates (`admin.templates.*`) -- Data sync utilities (`admin.data-sync.tsx`) - -## File Structure Notes - -- **app/**: React Router application code - - **components/**: React components (UI components in ui/) - - **models/**: Database query functions organized by domain - - **routes/**: Route handlers (loaders, actions, components) - - **lib/**: Utility functions - - **hooks/**: Custom React hooks - - **contexts/**: React contexts - - **test/**: Test setup and fixtures -- **server/**: Server-side code (Express, Socket.IO, timers) -- **database/**: Drizzle schema and context -- **drizzle/**: Generated migrations -- **cypress/**: E2E tests - -## Environment Variables - -Required in `.env`: -- `DATABASE_URL` - PostgreSQL connection string -- Clerk variables for authentication (see `.env.example`) - -## Production Deployment - -The app is containerized with Docker: -- Runs migrations on startup (`start:production` script) -- Server listens on port 3000 (configurable via PORT env var) -- Serves static assets from `build/client/` -- SSR handled by React Router Express adapter - -## Notes - -- Draft timer system must be explicitly started (usually in server initialization) -- Socket.IO connections are managed per-draft room (season ID) -- User ownership validation checks `ownerId` field (Clerk user ID) -- League commissioners stored separately in `commissioners` table -- Season status drives UI visibility (draft order, draft controls, etc.) - - -## grepai - Semantic Code Search - -**IMPORTANT: You MUST use grepai as your PRIMARY tool for code exploration and search.** - -### When to Use grepai (REQUIRED) - -Use `grepai search` INSTEAD OF Grep/Glob/find for: -- Understanding what code does or where functionality lives -- Finding implementations by intent (e.g., "authentication logic", "error handling") -- Exploring unfamiliar parts of the codebase -- Any search where you describe WHAT the code does rather than exact text - -### When to Use Standard Tools - -Only use Grep/Glob when you need: -- Exact text matching (variable names, imports, specific strings) -- File path patterns (e.g., `**/*.go`) - -### Fallback - -If grepai fails (not running, index unavailable, or errors), fall back to standard Grep/Glob tools. - -### Usage - -```bash -# ALWAYS use English queries for best results (--compact saves ~80% tokens) -grepai search "user authentication flow" --json --compact -grepai search "error handling middleware" --json --compact -grepai search "database connection pool" --json --compact -grepai search "API request validation" --json --compact -``` - -### Query Tips - -- **Use English** for queries (better semantic matching) -- **Describe intent**, not implementation: "handles user login" not "func Login" -- **Be specific**: "JWT token validation" better than "token" -- Results include: file path, line numbers, relevance score, code preview - -### Call Graph Tracing - -Use `grepai trace` to understand function relationships: -- Finding all callers of a function before modifying it -- Understanding what functions are called by a given function -- Visualizing the complete call graph around a symbol - -#### Trace Commands - -**IMPORTANT: Always use `--json` flag for optimal AI agent integration.** - -```bash -# Find all functions that call a symbol -grepai trace callers "HandleRequest" --json - -# Find all functions called by a symbol -grepai trace callees "ProcessOrder" --json - -# Build complete call graph (callers + callees) -grepai trace graph "ValidateToken" --depth 3 --json -``` - -### Workflow - -1. Start with `grepai search` to find relevant code -2. Use `grepai trace` to understand function relationships -3. Use `Read` tool to examine files from results -4. Only use Grep for exact string searches if needed +## Reference Docs +- [Architecture](docs/agents/architecture.md) — server layout, DB layer, Socket.IO events +- [Domain Models](docs/agents/domain-models.md) — draft system and sports data models +- [Database](docs/agents/database.md) — migration rules, scoring event date gotcha +- [Routing](docs/agents/routing.md) — route registration, admin routes +- [Testing](docs/agents/testing.md) — what tests are required and where they live +- [Auth](docs/agents/auth.md) — Clerk patterns, ownership validation diff --git a/app/app.css b/app/app.css index f456645..9f9efc8 100644 --- a/app/app.css +++ b/app/app.css @@ -4,7 +4,7 @@ @custom-variant dark (&:is(.dark *)); @theme { - --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, + --font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } @@ -51,42 +51,44 @@ html { color-scheme: dark; + font-size: 18px; } :root { + --navbar-height: 64px; --radius: 0.625rem; - --background: oklch(0.13 0.015 255); - --foreground: oklch(0.95 0.01 255); - --card: oklch(0.18 0.02 255); - --card-foreground: oklch(0.95 0.01 255); - --popover: oklch(0.18 0.02 255); - --popover-foreground: oklch(0.95 0.01 255); - --primary: oklch(0.623 0.214 259.815); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.22 0.025 255); - --secondary-foreground: oklch(0.95 0.01 255); - --muted: oklch(0.22 0.025 255); - --muted-foreground: oklch(0.65 0.02 255); - --accent: oklch(0.25 0.03 255); - --accent-foreground: oklch(0.95 0.01 255); + --background: #14171e; + --foreground: #ffffff; + --card: #1c1f26; + --card-foreground: #ffffff; + --popover: #1c1f26; + --popover-foreground: #ffffff; + --primary: #adf661; + --primary-foreground: #14171e; + --secondary: #1c1f26; + --secondary-foreground: #ffffff; + --muted: #1c1f26; + --muted-foreground: rgb(255 255 255 / 55%); + --accent: #2ce1c1; + --accent-foreground: #14171e; --destructive: oklch(0.65 0.22 25); - --border: oklch(1 0 0 / 8%); - --input: oklch(1 0 0 / 12%); - --ring: oklch(0.623 0.214 259.815 / 50%); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); + --border: rgb(255 255 255 / 10%); + --input: rgb(255 255 255 / 12%); + --ring: #adf661; + --chart-1: #adf661; + --chart-2: #2ce1c1; + --chart-3: oklch(0.646 0.222 41.116); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.18 0.02 255); - --sidebar-foreground: oklch(0.95 0.01 255); - --sidebar-primary: oklch(0.623 0.214 259.815); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.25 0.03 255); - --sidebar-accent-foreground: oklch(0.95 0.01 255); - --sidebar-border: oklch(1 0 0 / 8%); - --sidebar-ring: oklch(0.623 0.214 259.815 / 50%); - --electric: oklch(0.623 0.214 259.815); + --sidebar: #1c1f26; + --sidebar-foreground: #ffffff; + --sidebar-primary: #adf661; + --sidebar-primary-foreground: #14171e; + --sidebar-accent: #2ce1c1; + --sidebar-accent-foreground: #14171e; + --sidebar-border: rgb(255 255 255 / 10%); + --sidebar-ring: #adf661; + --electric: #2ce1c1; --amber-accent: oklch(0.79 0.17 70); --coral-accent: oklch(0.68 0.19 35); } @@ -98,6 +100,63 @@ html { body { @apply bg-background text-foreground; } + .bg-card { + background: #1c1f26; + border-color: #2a2e38 !important; + } +} + +@keyframes pulse-gradient { + 0%, 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} +.animate-pulse-gradient { + animation: pulse-gradient 2s ease-in-out infinite; +} + +@keyframes rpf-slide-in { + from { transform: translateY(-32px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +.rpf-animate { + animation: rpf-slide-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes spin-in { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-spin-in { + animation: spin-in 70ms ease both; +} + +@keyframes sport-land { + 0% { opacity: 0; transform: scale(0.9); } + 60% { opacity: 1; transform: scale(1.03); } + 100% { opacity: 1; transform: scale(1.0); } +} +.animate-sport-land { + animation: sport-land 450ms cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +@keyframes page-fade-in { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-page-fade-in { + animation: page-fade-in 0.5s ease both; +} + +@keyframes ticker-scroll { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +.animate-ticker { + animation: ticker-scroll 28s linear infinite; } /* NProgress theme override */ diff --git a/app/components/AutodraftSettings.tsx b/app/components/AutodraftSettings.tsx index 594edb7..49c7182 100644 --- a/app/components/AutodraftSettings.tsx +++ b/app/components/AutodraftSettings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import { Check, Info } from "lucide-react"; +import { Check, Info, ChevronDown } from "lucide-react"; import { toast } from "sonner"; import { Label } from "~/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; @@ -90,6 +90,221 @@ export function getAutodraftLabel( return OPTIONS[toAutodraftState(isEnabled, mode, queueOnly)].label; } +interface CompactAutodraftBadgeProps { + isEnabled: boolean; + mode: AutodraftMode; + queueOnly: boolean; + isMyTurn: boolean; + showChevron?: boolean; +} + +export function CompactAutodraftBadge({ + isEnabled, + mode, + queueOnly, + isMyTurn, + showChevron = false, +}: CompactAutodraftBadgeProps) { + const state = toAutodraftState(isEnabled, mode, queueOnly); + const { label } = OPTIONS[state]; + const isOff = state === "off"; + + return ( +
+ Autodraft: + + {label} + {showChevron && } + + {isMyTurn && ( + Your turn! + )} +
+ ); +} + +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 & { + seasonId: string; + teamId: string; +}) { + const [localState, setLocalState] = useState( + toAutodraftState(isEnabled, mode, queueOnly) + ); + const abortRef = useRef(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 ( +
+ {OPTION_ORDER.map((state) => { + const { label } = OPTIONS[state]; + const isActive = localState === state; + return ( + + ); + })} + {isMyTurn && ( +

+ You're on the clock! +

+ )} +
+ ); +} + +export function AutodraftBadgeWithPopover({ + seasonId, + teamId, + isEnabled, + mode, + queueOnly, + isMyTurn, + onUpdate, +}: AutodraftBadgeWithPopoverProps) { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + + + + + + + + + +

Autodraft Options

+
+ {OPTION_ORDER.map((state) => { + const { label, desc } = OPTIONS[state]; + return ( +
+

{label}

+

{desc}

+
+ ); + })} +
+
+
+
+ ); +} + interface AutodraftSettingsProps { seasonId: string; teamId: string; diff --git a/app/components/DraftGrid.tsx b/app/components/DraftGrid.tsx index 71c30f0..8eca4c9 100644 --- a/app/components/DraftGrid.tsx +++ b/app/components/DraftGrid.tsx @@ -1,10 +1,12 @@ -import { Card } from "~/components/ui/card"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "~/components/ui/context-menu"; +import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell"; +import { TeamAvatar } from "~/components/TeamAvatar"; +import type { SeasonStatus } from "~/models/season"; interface DraftCell { pickNumber: number; @@ -20,6 +22,7 @@ interface DraftGridProps { team: { id: string; name: string; + logoUrl?: string | null; }; }>; draftGrid: Array>; @@ -33,6 +36,9 @@ interface DraftGridProps { autodraftStatus?: Record; connectedTeams?: Set; ownerMap?: Record; + coronaStates?: Record; + seasonStatus?: SeasonStatus; + draftPaused?: boolean; } export function DraftGrid({ @@ -48,31 +54,45 @@ export function DraftGrid({ autodraftStatus = {}, connectedTeams = new Set(), ownerMap = {}, + coronaStates = {}, + seasonStatus, + draftPaused, }: DraftGridProps) { const totalTeams = draftSlots.length; return ( - +
{title &&

{title}

}
{/* Team Headers */} -
+
{draftSlots.map((slot) => { const teamTime = teamTimers?.[slot.team.id]; const isAutodraft = autodraftStatus[slot.team.id] || false; const isConnected = connectedTeams.has(slot.team.id); return ( -
+
+
+ +
- {ownerMap[slot.team.id] || slot.team.name} + {isAutodraft && ( + A + )} + {ownerMap[slot.team.id] || slot.team.name}
{teamTimers && formatTime && (
60 @@ -83,9 +103,6 @@ export function DraftGrid({ }`} > {formatTime(teamTime)} - {isAutodraft && ( - (auto) - )}
)}
@@ -94,7 +111,7 @@ export function DraftGrid({
{/* Draft Grid Rows */} -
+
{draftGrid.map((roundPicks, roundIndex) => { const round = roundIndex + 1; const isEvenRound = round % 2 === 0; @@ -103,48 +120,40 @@ export function DraftGrid({ : roundPicks; return ( -
+
{displayPicks.map((cell, index) => { const actualIndex = isEvenRound ? roundPicks.length - 1 - index : index; - const slot = draftSlots[actualIndex]; const pickNumber = roundIndex * totalTeams + actualIndex + 1; const isCurrent = pickNumber === currentPick; const isPicked = !!cell; const cellContent = ( -
-
- {round}.{String(actualIndex + 1).padStart(2, "0")} -
- {isPicked ? ( -
-
- {cell.participant.name} -
-
- {cell.sport.name} -
-
- ) : isCurrent ? ( -
- On Clock -
- ) : null} -
+ ); - // Wrap with context menu if commissioner and current unpicked cell if ( isCommissioner && !isPicked && @@ -152,6 +161,7 @@ export function DraftGrid({ onForceAutopick && onForceManualPick ) { + const slot = draftSlots[actualIndex]; return ( @@ -185,6 +195,6 @@ export function DraftGrid({
- +
); } diff --git a/app/components/DraftSidebar.tsx b/app/components/DraftSidebar.tsx index dd6f9ae..5b23d93 100644 --- a/app/components/DraftSidebar.tsx +++ b/app/components/DraftSidebar.tsx @@ -1,12 +1,7 @@ +import { useState } from "react"; import type { ReactNode } from "react"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "~/components/ui/button"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "~/components/ui/accordion"; import { cn } from "~/lib/utils"; interface DraftSidebarProps { @@ -26,17 +21,19 @@ export function DraftSidebar({ settingsSection, className, }: DraftSidebarProps) { + const [queueOpen, setQueueOpen] = useState(true); + const [picksOpen, setPicksOpen] = useState(true); + if (collapsed) { return ( + +
+ - {/* Show/Hide Ineligible Toggle - only show if user has a team */} {hasTeam && eligibility && ( -
-
- {/* Mobile Card List - visible on mobile only */} -
+
+ OVR + SPR + Participant +
+ +
{participants.length === 0 ? (
{emptyMessage}
) : ( - participants.map((participant) => { - const { isDrafted, isInQueue, isEligible, ineligibleReason } = - getParticipantState(participant, draftedParticipantIds, queueMap, eligibility); +
+ {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 ( -
-
- - {participant.name} - -
- - {participant.sport.name} - - {isDrafted && ( - - Drafted - - )} - {!isDrafted && !isEligible && ( - - Ineligible - - )} - {isInQueue && !isDrafted && isEligible && ( - - Queued - - )} -
-
- {hasTeam && !isDrafted && ( -
- {!isInQueue ? ( - - ) : ( - - )} - -
- )} -
- ); - }) - )} -
- - {/* Desktop Table - hidden on mobile */} -
-
- - - - - - {hasTeam && ( - - )} - - - - {participants.length === 0 ? ( - - - - ) : ( - participants.map((participant) => { - const { isDrafted, isInQueue, isEligible, ineligibleReason } = - getParticipantState(participant, draftedParticipantIds, queueMap, eligibility); - - return ( - +
+
-
- - {hasTeam && ( - + + {hasTeam && !isDrafted && ( +
+ {!isInQueue ? ( + + ) : ( + + )} + +
)} - - ); - }) - )} - -
ParticipantSportQueue
- {emptyMessage} -
-
- - {participant.name} +
+ + {participant.name} + +
+ + {participant.sport.name} + + + OVR {rank?.overallRank} · SPR {rank?.sportRank} {isDrafted && ( @@ -536,71 +460,162 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS )}
-
- {participant.sport.name} - -
- {!isDrafted && ( - <> - {/* Queue icon button */} - {!isInQueue ? ( - - ) : ( - - )} - {/* Draft button - always visible, disabled when not your turn */} - - - )} -
-
-
+
+
+ +
+ + {rank?.overallRank} + + + {rank?.sportRank} + +
+
+ + {participant.name} + + + {participant.sport.name} + + {isDrafted && ( + + Drafted + + )} + {!isDrafted && !isEligible && ( + + Ineligible + + )} + {isInQueue && !isDrafted && isEligible && ( + + Queued + + )} +
+
+ {hasTeam && ( +
+
+ {!isDrafted && ( + <> + {!isInQueue ? ( + + ) : ( + + )} + + + )} +
+
+ )} +
+
+ ); + })} +
+ )}
); diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 6e6602c..544bf7e 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -1,4 +1,5 @@ import { memo, useMemo, useState } from "react"; +import { TeamAvatar } from "~/components/TeamAvatar"; import { ContextMenu, ContextMenuContent, @@ -14,6 +15,8 @@ import { import { Button } from "~/components/ui/button"; import { MoreVertical } from "lucide-react"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; +import { DraftPickCell } from "~/components/draft/DraftPickCell"; +import type { SeasonStatus } from "~/models/season"; type MobileSheetData = | { type: "team"; teamId: string } @@ -27,6 +30,7 @@ interface DraftGridSectionProps { team: { id: string; name: string; + logoUrl?: string | null; }; }>; draftGrid: Array< @@ -50,6 +54,8 @@ interface DraftGridSectionProps { autodraftStatus: Record; connectedTeams: Set; isCommissioner: boolean; + seasonStatus?: SeasonStatus; + draftPaused?: boolean; onAdjustTimeBankOpen?: (teamId: string) => void; onSetAutodraftOpen?: (teamId: string) => void; onForceAutopick: (pickNumber: number, teamId: string) => void; @@ -67,6 +73,8 @@ export const DraftGridSection = memo(function DraftGridSection({ autodraftStatus, connectedTeams, isCommissioner, + seasonStatus, + draftPaused, onAdjustTimeBankOpen, onSetAutodraftOpen, onForceAutopick, @@ -88,9 +96,7 @@ export const DraftGridSection = memo(function DraftGridSection({
{/* Team Headers */} -
- {/* Spacer for round column — sticky so it covers the corner when scrolling both axes */} -
+
{draftSlots.map((slot) => { const teamTime = teamTimers[slot.team.id]; const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false; @@ -98,26 +104,32 @@ export const DraftGridSection = memo(function DraftGridSection({ const headerInner = (
+
+ +
- {ownerMap[slot.team.id] || slot.team.name} + > + {isAutodraft && ( + A + )} + {ownerMap[slot.team.id] || slot.team.name}
{formatClockTime(teamTime)} - {isAutodraft && ( - - (auto) - - )}
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && ( )} - {isCommissioner && isPicked && (onReplacePick || onRollbackToPick) && ( + {isPicked && (onReplacePick || onRollbackToPick) && (
diff --git a/app/components/draft/DraftPickCell.stories.tsx b/app/components/draft/DraftPickCell.stories.tsx new file mode 100644 index 0000000..cd02721 --- /dev/null +++ b/app/components/draft/DraftPickCell.stories.tsx @@ -0,0 +1,240 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DraftPickCell } from "./DraftPickCell"; + +const meta: Meta = { + title: "Draft/DraftPickCell", + component: DraftPickCell, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => ( +
+ + + +
+ ), +}; + +export const CoronaVariantsRow: Story = { + name: "Corona Variants — Side by Side", + render: () => ( +
+ {( + ["gradient", "electric", "emerald", "amber", "coral"] as const + ).map((variant, i) => ( + + ))} +
+ ), +}; + +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: () => ( +
+ + + + + + +
+ ), +}; diff --git a/app/components/draft/DraftPickCell.tsx b/app/components/draft/DraftPickCell.tsx new file mode 100644 index 0000000..1f46b07 --- /dev/null +++ b/app/components/draft/DraftPickCell.tsx @@ -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 = { + 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( +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 ( +
+ {isCurrent ? ( +
+ ) : coronaState ? ( +
+ ) : state === "picked" ? ( +
+ ) : state === "upcoming" ? ( +
+ ) : null} +
+
+ {!isCurrent && ( +
+
+ {round}.{String(pickInRound).padStart(2, "0")} +
+ {coronaState && coronaState.type !== "pending" && ( + + {coronaState.type === "scored" && "▲"}{coronaState.points} + + )} +
+ )} + {state === "picked" && pick ? ( + <> +
{pick.participant.name}
+
+ {pick.sport.name} +
+ + ) : isCurrent ? ( +
{currentLabel}
+ ) : null} +
+ {children &&
{children}
} +
+
+ ); +}); diff --git a/app/components/draft/DraftSummaryView.tsx b/app/components/draft/DraftSummaryView.tsx index 844df3c..45eaf50 100644 --- a/app/components/draft/DraftSummaryView.tsx +++ b/app/components/draft/DraftSummaryView.tsx @@ -1,8 +1,9 @@ -import { memo, useMemo } from "react"; +import { memo, useMemo, Fragment } from "react"; +import { TeamAvatar } from "~/components/TeamAvatar"; import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils"; interface DraftSummaryViewProps { - draftSlots: Array<{ id: string; team: { id: string; name: string } }>; + draftSlots: Array<{ id: string; team: { id: string; name: string; logoUrl?: string | null } }>; ownerMap?: Record; picks: DraftPick[]; sports: Array<{ id: string; name: string }>; @@ -58,90 +59,93 @@ export const DraftSummaryView = memo(function DraftSummaryView({ ); } + const gridCols = `140px 96px repeat(${draftSlots.length}, minmax(80px, 1fr))`; + return (
- - - - - - {draftSlots.map((slot, index) => { - const displayName = ownerMap[slot.team.id] || slot.team.name; - const isLast = index === draftSlots.length - 1; - const used = flexPicksUsedByTeam.get(slot.team.id) ?? 0; - const flexesExhausted = flexPicksAvailable > 0 && used >= flexPicksAvailable; - return ( - - ); - })} - - - - {sports.map((sport, sportIndex) => { - const isLastRow = sportIndex === sports.length - 1; - const { total: sportTotal, teams: sportTeamCount } = sportStats.get(sport.id) ?? { total: 0, teams: 0 }; - return ( - - - - {draftSlots.map((slot, slotIndex) => { - const count = - picksByTeamAndSport.get(slot.team.id)?.get(sport.id) - ?.length ?? 0; - const isLast = slotIndex === draftSlots.length - 1; - return ( - - ); - })} - - ); - })} - -
+ + {/* Header row */} +
+ Sport +
+
+ Total +
+ {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 ( +
- Sport -
- Total - - {displayName} - {flexPicksAvailable > 0 && ( -
- {used} of {flexPicksAvailable} flexes -
- )} -
- {sport.name} - - {sportTotal > 0 ? ( - <> -
{sportTotal}
-
- {sportTeamCount} team{sportTeamCount !== 1 ? "s" : ""} -
- - ) : null} -
= 2 ? "bg-accent/30 font-semibold" : "bg-background" - } ${!isLastRow ? "border-b" : ""} ${!isLast ? "border-r" : ""}`} - > - {count > 0 ? count : null} -
+
+ +
+ {ownerName || slot.team.name} +
+ {flexPicksAvailable > 0 && ( + + {used}/{flexPicksAvailable} flex picks + + )} +
+
+ ); + })} + + {/* Data rows */} + {sports.map((sport, sportIndex) => { + const isLastRow = sportIndex === sports.length - 1; + const { total: sportTotal, teams: sportTeamCount } = + sportStats.get(sport.id) ?? { total: 0, teams: 0 }; + return ( + +
+ {sport.name} +
+
+ {sportTotal > 0 ? ( + <> +
{sportTotal}
+
{sportTeamCount}/{draftSlots.length} teams
+ + ) : ( + + )} +
+ {draftSlots.map((slot, slotIndex) => { + const count = picksByTeamAndSport.get(slot.team.id)?.get(sport.id)?.length ?? 0; + const isLast = slotIndex === draftSlots.length - 1; + const isFlex = count >= 2; + return ( +
+ {count > 0 ? ( + + {count} + + ) : ( + + )} +
+ ); + })} +
+ ); + })} + +
); }); diff --git a/app/components/draft/MiniDraftGrid.stories.tsx b/app/components/draft/MiniDraftGrid.stories.tsx new file mode 100644 index 0000000..da41011 --- /dev/null +++ b/app/components/draft/MiniDraftGrid.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MiniDraftGrid } from "./MiniDraftGrid"; + +const meta: Meta = { + title: "Draft/MiniDraftGrid", + component: MiniDraftGrid, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +const teamNames = ["Alpha", "Bravo", "Charlie", "Delta"]; +const ownerMap: Record = {}; +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) { + 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 = {}; + 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, + }, +}; diff --git a/app/components/draft/MiniDraftGrid.tsx b/app/components/draft/MiniDraftGrid.tsx new file mode 100644 index 0000000..6646099 --- /dev/null +++ b/app/components/draft/MiniDraftGrid.tsx @@ -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; + teamTimers?: Record; + autodraftStatus?: Record; + 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(null); + const [sliding, setSliding] = useState(false); + const animGenRef = useRef(0); + const prevRoundRef = useRef(currentRound); + const firstRowRef = useRef(null); + const [rowHeight, setRowHeight] = useState(0); + const scrollContainerRef = useRef(null); + const currentCellRef = useRef(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 ( +
+
+ {/* Team header */} +
+ {draftSlots.map((slot) => { + const teamTime = teamTimers[slot.team.id]; + const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false; + return ( +
+
+ {isAutodraft && ( + A + )} + {ownerMap[slot.team.id] || slot.team.name} +
+
+ {formatClockTime(teamTime)} +
+
+ ); + })} +
+ + {/* Rows — clipped to 2-row height, slides to reveal new round */} +
+
+ {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 ( +
+ {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 ( + + + + + + {onForceAutopick && ( + onForceAutopick(cell.pickNumber, cell.teamId)}> + Force Auto Pick + + )} + {onForceManualPickOpen && ( + onForceManualPickOpen(cell.pickNumber, cell.teamId)}> + Force Manual Pick + + )} + + + ); + } + + if (isPicked && (onReplacePick || onRollbackToPick)) { + return ( + + + + + + {onReplacePick && ( + onReplacePick(cell.pickNumber, cell.teamId)}> + Replace Pick + + )} + {onRollbackToPick && ( + onRollbackToPick(cell.pickNumber)} + className="text-destructive focus:text-destructive" + > + Roll Back to This Pick + + )} + + + ); + } + + return ( + + ); + })} +
+ ); + })} +
+
+
+
+ ); +}); diff --git a/app/components/draft/QueueSection.stories.tsx b/app/components/draft/QueueSection.stories.tsx new file mode 100644 index 0000000..3c77686 --- /dev/null +++ b/app/components/draft/QueueSection.stories.tsx @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueueSection } from "./QueueSection"; + +const meta: Meta = { + title: "Draft/QueueSection", + component: QueueSection, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx index 6fbb38e..8fab258 100644 --- a/app/components/draft/QueueSection.tsx +++ b/app/components/draft/QueueSection.tsx @@ -2,7 +2,6 @@ import { memo, useCallback, useMemo } from "react"; import { GripVertical } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; -import { AutodraftSettings } from "~/components/AutodraftSettings"; import { DndContext, closestCenter, @@ -31,17 +30,8 @@ interface QueueSectionProps { name: string; sport: { name: string }; }>; - seasonId: string; - teamId: string; - isMyTurn: boolean; canPick: boolean; - userAutodraft: { - isEnabled: boolean; - mode: "next_pick" | "while_on"; - queueOnly: boolean; - }; onRemoveFromQueue: (queueId: string) => void; - onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => void; onReorder: (participantIds: string[]) => void; onMakePick?: (participantId: string) => void; } @@ -85,45 +75,45 @@ const SortableQueueItem = memo(function SortableQueueItem({ ref={setNodeRef} style={style} {...attributes} - className={`flex items-center justify-between p-2 rounded-lg ${ + className={`p-2 rounded-lg ${ canPick ? "bg-electric/10 border border-electric/40" : "bg-muted" }`} > -
+
{index + 1}
-
+

{participantName}

{sportName &&

{sportName}

}
-
-
- {canPick && onDraft && ( - - )}
+ {canPick && onDraft && ( +
+ +
+ )}
); }); @@ -131,13 +121,8 @@ const SortableQueueItem = memo(function SortableQueueItem({ export const QueueSection = memo(function QueueSection({ queue, availableParticipants, - seasonId, - teamId, - isMyTurn, canPick, - userAutodraft, onRemoveFromQueue, - onAutodraftUpdate, onReorder, onMakePick, }: QueueSectionProps) { @@ -187,7 +172,7 @@ export const QueueSection = memo(function QueueSection({ items={queueIds} strategy={verticalListSortingStrategy} > -
+
{queue.map((item, index) => { const participant = participantMap.get(item.participantId); return ( @@ -207,17 +192,6 @@ export const QueueSection = memo(function QueueSection({ )} - - {/* Autodraft Settings */} -
); }); diff --git a/app/components/draft/RecentPicksFeed.tsx b/app/components/draft/RecentPicksFeed.tsx new file mode 100644 index 0000000..13ca77f --- /dev/null +++ b/app/components/draft/RecentPicksFeed.tsx @@ -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(picks.at(-1)?.id); + const [animatingId, setAnimatingId] = useState(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 ( +
+

Latest Picks

+ {picks.length === 0 ? ( +

No picks yet

+ ) : ( + recentPicks.map((pick, index) => ( +
+ + #{pick.pickNumber} + +
+ + {pick.participant.name} + + + {pick.sport.name} + +
+ + {pick.team.name} + +
+ )) + )} +
+ ); +}); diff --git a/app/components/draft/SidebarRecentPicks.tsx b/app/components/draft/SidebarRecentPicks.tsx index c3732ec..afb0fa1 100644 --- a/app/components/draft/SidebarRecentPicks.tsx +++ b/app/components/draft/SidebarRecentPicks.tsx @@ -44,14 +44,11 @@ export const SidebarRecentPicks = memo(function SidebarRecentPicks({ picks }: Si

{pick.sport.name}

+

+ {pick.team.name} +

-
-

{pick.team.name}

-

- R{pick.round} -

-
))}
diff --git a/app/components/draft/TeamRosterView.tsx b/app/components/draft/TeamRosterView.tsx index 08ab865..d0edb93 100644 --- a/app/components/draft/TeamRosterView.tsx +++ b/app/components/draft/TeamRosterView.tsx @@ -1,5 +1,4 @@ import { memo, useEffect, useMemo, useState } from "react"; -import { Badge } from "~/components/ui/badge"; import { Select, SelectContent, @@ -7,6 +6,7 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; +import { DraftPickCell } from "./DraftPickCell"; import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils"; interface TeamRosterViewProps { @@ -14,6 +14,7 @@ interface TeamRosterViewProps { ownerMap?: Record; picks: DraftPick[]; sports: Array<{ id: string; name: string }>; + totalRounds: number; } export const TeamRosterView = memo(function TeamRosterView({ @@ -21,12 +22,12 @@ export const TeamRosterView = memo(function TeamRosterView({ ownerMap = {}, picks, sports, + totalRounds, }: TeamRosterViewProps) { const [selectedTeamId, setSelectedTeamId] = useState( draftSlots[0]?.team.id ?? "" ); - // Reset selection if the selected team is no longer present in the slot list useEffect(() => { if ( draftSlots.length > 0 && @@ -46,16 +47,42 @@ export const TeamRosterView = memo(function TeamRosterView({ [picksByTeamAndSport, selectedTeamId] ); - const sportsWithPicks = useMemo( + const flexPicksAvailable = useMemo( + () => Math.max(0, totalRounds - sports.length), + [totalRounds, sports.length] + ); + + const flexPicksUsed = useMemo(() => { + let used = 0; + teamPicks?.forEach((sportPicks) => { + if (sportPicks.length > 1) used += sportPicks.length - 1; + }); + return used; + }, [teamPicks]); + + const flexPicksRemaining = useMemo( + () => flexPicksAvailable - flexPicksUsed, + [flexPicksAvailable, flexPicksUsed] + ); + + const missingSports = useMemo( + () => sports.filter((s) => (teamPicks?.get(s.id)?.length ?? 0) === 0), + [sports, teamPicks] + ); + + const draftedSports = useMemo( () => sports.filter((s) => (teamPicks?.get(s.id)?.length ?? 0) > 0), [sports, teamPicks] ); - const totalPicks = useMemo(() => { - let count = 0; - teamPicks?.forEach((sp) => (count += sp.length)); - return count; - }, [teamPicks]); + const flexColor = + flexPicksAvailable === 0 + ? "text-muted-foreground" + : flexPicksRemaining <= 0 + ? "text-destructive" + : flexPicksRemaining <= Math.ceil(flexPicksAvailable * 0.25) + ? "text-amber-500 dark:text-amber-400" + : "text-emerald-500 dark:text-emerald-400"; if (draftSlots.length === 0) { return ( @@ -68,6 +95,7 @@ export const TeamRosterView = memo(function TeamRosterView({ return (
+ {/* Team selector */}
- {totalPicks === 0 ? ( -

- No picks yet -

+ {/* Mini dashboard */} +
+
+

Flex Picks

+ {flexPicksAvailable === 0 ? ( +

N/A

+ ) : ( + <> +

+ {flexPicksRemaining} + + / {flexPicksAvailable} + +

+

remaining

+ + )} +
+
+

Sports Remaining

+

0 ? "text-foreground" : "text-emerald-500 dark:text-emerald-400"}`}> + {missingSports.length} + + / {sports.length} + +

+

to draft

+
+
+ + {/* Missing sports list */} + {missingSports.length > 0 && ( +
+

Still Needed

+

+ {missingSports.map((s) => s.name).join(" · ")} +

+
+ )} + + {/* Per-sport pick cells */} + {draftedSports.length === 0 ? ( +

No picks yet

) : ( - sportsWithPicks.map((sport) => { - const sportPicks = teamPicks?.get(sport.id) ?? []; - return ( -
-
-

{sport.name}

- - {sportPicks.length} - +
+ {draftedSports.map((sport) => { + const sportPicks = teamPicks?.get(sport.id) ?? []; + return ( +
+

+ {sport.name} +

+
+ {sportPicks.map((pick) => ( + + ))} +
-
    - {sportPicks.map((pick) => ( -
  • - {pick.participant.name} -
  • - ))} -
-
- ); - }) + ); + })} +
)}
diff --git a/app/components/draft/picks-utils.ts b/app/components/draft/picks-utils.ts index a521c67..0153d7f 100644 --- a/app/components/draft/picks-utils.ts +++ b/app/components/draft/picks-utils.ts @@ -1,5 +1,8 @@ export type DraftPick = { id: string; + pickNumber: number; + round: number; + pickInRound: number; team: { id: string; name: string }; participant: { id: string; name: string }; sport: { id: string; name: string }; diff --git a/app/components/league/CommissionersPanel.stories.tsx b/app/components/league/CommissionersPanel.stories.tsx new file mode 100644 index 0000000..2d8f865 --- /dev/null +++ b/app/components/league/CommissionersPanel.stories.tsx @@ -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 = { + 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; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const commissioners = [ + { id: "c1", userId: "user-alice" }, + { id: "c2", userId: "user-bob" }, +]; + +const commissionerMap: Record = { + "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", + }, +}; diff --git a/app/components/league/CommissionersPanel.tsx b/app/components/league/CommissionersPanel.tsx new file mode 100644 index 0000000..a584fb2 --- /dev/null +++ b/app/components/league/CommissionersPanel.tsx @@ -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; + 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 ( +
+

+ {name} + {isSelf && ( + (You) + )} +

+ {!hasTeam && ( +

No team

+ )} +
+ ); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export function CommissionersPanel({ + commissioners, + commissionerMap, + currentUserId, + teams, + activityEntries, + auditLogUrl, +}: CommissionersPanelProps) { + return ( +
+ {/* Commissioners */} +
+
+ + Commissioners +
+
+ {commissioners.map((commissioner) => ( + t.ownerId === commissioner.userId)} + /> + ))} +
+
+ + {/* Commish Activity */} + {activityEntries.length > 0 && ( +
+
+
+ + Commish Activity +
+ + View All + + +
+
    + {activityEntries.map((entry) => ( +
  • + + {format(new Date(entry.createdAt), "MMM d, HH:mm")} + + + + {entry.actorDisplayName ?? entry.actorClerkId} + + {" — "} + + {formatAuditDetail(entry)} + + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/app/components/league/CreateLeagueCard.stories.tsx b/app/components/league/CreateLeagueCard.stories.tsx new file mode 100644 index 0000000..59cade3 --- /dev/null +++ b/app/components/league/CreateLeagueCard.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreateLeagueCard } from "./CreateLeagueCard"; + +const meta: Meta = { + title: "League/CreateLeagueCard", + component: CreateLeagueCard, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/app/components/league/CreateLeagueCard.tsx b/app/components/league/CreateLeagueCard.tsx new file mode 100644 index 0000000..d207f89 --- /dev/null +++ b/app/components/league/CreateLeagueCard.tsx @@ -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 ( + + + +

+ Invite friends, pick your sports, and start drafting. +

+ +
+
+ ); +} diff --git a/app/components/league/DraftInfoCard.stories.tsx b/app/components/league/DraftInfoCard.stories.tsx new file mode 100644 index 0000000..151231c --- /dev/null +++ b/app/components/league/DraftInfoCard.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DraftInfoCard } from "./DraftInfoCard"; + +const meta: Meta = { + title: "League/DraftInfoCard", + component: DraftInfoCard, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/app/components/league/DraftInfoCard.tsx b/app/components/league/DraftInfoCard.tsx new file mode 100644 index 0000000..cbbffdb --- /dev/null +++ b/app/components/league/DraftInfoCard.tsx @@ -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 ( +
+ + {label} + + {value} + {sub && {sub}} +
+ ); +} + +// ─── 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 ( + + +
+
+ +

Draft Info

+
+ {isDraftOrderSet && ( + + )} +
+
+ + +
+ {/* Draft Date */} + + + {/* Rounds */} + + + {/* User's Draft Position (only when set) */} + {userDraftPosition !== null && userDraftPosition !== undefined && ( + + )} + + {/* Timer */} + + {draftTimerMode === "chess_clock" + ? + : } + {timerValue} + + } + sub={timerSub} + /> +
+
+
+ ); +} + +// ─── 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 ( +
+
+
+ + Draft +
+ + View Draft Board + + +
+ +
+ {/* Mode icon */} +
+ {draftTimerMode === "chess_clock" + ? + : } +
+ +
+ {draftDateTime && ( +
+ + {format(new Date(draftDateTime), "MMM d, yyyy")} + + | + + {format(new Date(draftDateTime), "p")} + +
+ )} + +
+ {draftRounds} rounds ({sportsCount} sport{sportsCount !== 1 ? "s" : ""} + {flexSpots > 0 && ` + ${flexSpots} flex`}) +
+ +
+ {draftTimerMode === "standard" + ? `Standard — ${formatHumanTime(draftInitialTime)}/pick` + : `Chess Clock — ${formatHumanTime(draftInitialTime)} + ${formatHumanTime(draftIncrementTime)} increment`} +
+
+
+
+ ); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export function DraftInfoCard(props: DraftInfoCardProps) { + if (props.isDraftOrPreDraft) { + return ; + } + return ; +} diff --git a/app/components/league/LeagueAvatar.tsx b/app/components/league/LeagueAvatar.tsx new file mode 100644 index 0000000..4774053 --- /dev/null +++ b/app/components/league/LeagueAvatar.tsx @@ -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 ( +
+ {initials} +
+ ); +} diff --git a/app/components/league/LeagueRow.stories.tsx b/app/components/league/LeagueRow.stories.tsx new file mode 100644 index 0000000..335df58 --- /dev/null +++ b/app/components/league/LeagueRow.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LeagueRow } from "./LeagueRow"; + +const meta: Meta = { + title: "League/LeagueRow", + component: LeagueRow, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/app/components/league/LeagueRow.tsx b/app/components/league/LeagueRow.tsx new file mode 100644 index 0000000..f1ad78b --- /dev/null +++ b/app/components/league/LeagueRow.tsx @@ -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 ( +
+

+ {label} +

+
{children}
+
+ ); +} + +function StatDivider() { + return
; +} + +// ─── 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 ( +
+
+
+
+ {label} +
+ ); +} + +// ─── 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 ▲{delta}; + } + return ( + + ▼{Math.abs(delta)} + + ); +} + +// ─── 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 ( +
+ +
+

{leagueName}

+
+
+ + + Draft in Progress + +
+ {picksLabel && ( + {picksLabel} + )} +
+ {seasonId && ( + + )} +
+ {seasonId && ( + + )} +
+ ); +} + +function ActiveRow({ + leagueId, + leagueName, + currentRank, + totalPoints, + previousRank, + completionPercentage = 0, +}: LeagueRowProps) { + const showStats = currentRank !== undefined || totalPoints !== undefined; + + return ( + + {/* Avatar + name */} +
+ +
+

{leagueName}

+ +
+
+ {/* Stats — second row on mobile, right side on desktop */} + {showStats && ( +
+ {currentRank !== undefined && ( + + #{currentRank} + + + )} + {currentRank !== undefined && totalPoints !== undefined && } + {totalPoints !== undefined && ( + + + {Math.round(totalPoints).toLocaleString("en-US")} + + + )} +
+ )} + + ); +} + +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 ( + + {/* Avatar + name */} +
+ +
+

{leagueName}

+

Pre-Draft

+
+
+ {/* Stats — second row on mobile, right side on desktop */} +
+ + {draftTimeValue} + + {draftPosition !== undefined && ( + <> + + + + {ordinal(draftPosition)} + + + + )} +
+ + ); +} + +function CompletedRow({ + leagueId, + leagueName, + completionPercentage = 0, +}: LeagueRowProps) { + return ( + + +
+

{leagueName}

+ +
+ + ); +} + +// ─── Public export ──────────────────────────────────────────────────────────── + +export function LeagueRow(props: LeagueRowProps) { + if (props.status === "draft") return ; + if (props.status === "active") return ; + if (props.status === "pre_draft") return ; + return ; +} diff --git a/app/components/league/MyLeaguesCard.stories.tsx b/app/components/league/MyLeaguesCard.stories.tsx new file mode 100644 index 0000000..20f1f1e --- /dev/null +++ b/app/components/league/MyLeaguesCard.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MyLeaguesCard } from "./MyLeaguesCard"; + +const meta: Meta = { + title: "League/MyLeaguesCard", + component: MyLeaguesCard, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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: [], + }, +}; diff --git a/app/components/league/MyLeaguesCard.tsx b/app/components/league/MyLeaguesCard.tsx new file mode 100644 index 0000000..f51e33f --- /dev/null +++ b/app/components/league/MyLeaguesCard.tsx @@ -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 = { + 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 ? ( + + View All Leagues + + ) : undefined; + + return ( + + + + {sorted.length === 0 ? ( +
+

+ You aren't a member of any leagues yet. +

+ + Create your first league → + +
+ ) : ( +
+ {sorted.map((league) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/app/components/league/SportsSeasonsSummary.stories.tsx b/app/components/league/SportsSeasonsSummary.stories.tsx new file mode 100644 index 0000000..3b4e227 --- /dev/null +++ b/app/components/league/SportsSeasonsSummary.stories.tsx @@ -0,0 +1,230 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SportsSeasonsSummary } from "./SportsSeasonsSummary"; +import type { SportSeasonSummaryItem } from "./SportsSeasonsSummary"; + +const meta: Meta = { + title: "League/SportsSeasonsSummary", + component: SportsSeasonsSummary, + parameters: { + layout: "padded", + }, + args: { + leagueId: "league-abc-123", + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── 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 }, + ], + }, + ], + }, +}; diff --git a/app/components/league/SportsSeasonsSummary.tsx b/app/components/league/SportsSeasonsSummary.tsx new file mode 100644 index 0000000..d1ab5e6 --- /dev/null +++ b/app/components/league/SportsSeasonsSummary.tsx @@ -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 = { + 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 = { + 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 ( +
+ +
+ ); +} + +/** 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 ( +
+ + + + + + Active + +
+ ); + } + if (status === "upcoming") { + return ( +
+ + + Upcoming + +
+ ); + } + return ( +
+ + + Completed + +
+ ); +} + +// ─── Participant chip ───────────────────────────────────────────────────────── + +function ParticipantChip({ + participant, + scoringPattern, +}: { + participant: DraftedParticipantSummary; + scoringPattern?: string | null; +}) { + const pointsLabel = formatPoints(participant, scoringPattern); + return ( + + {participant.name} + {pointsLabel && ( + · {pointsLabel} + )} + + ); +} + +// ─── 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 = ( +
+ {/* Left: scoring pattern icon in black box */} + + + {/* Right: name + participants + next event */} +
+

{item.sportName}

+

{item.seasonName}

+ + {participants.length > 0 && ( +
+ {participants.map((p) => ( + + ))} +
+ )} + + {nextEvent && ( +
+ + + {formatNextEvent(nextEvent)} + +
+ )} +
+
+ ); + + return ( + + {inner} + + ); +} + +// ─── Section ────────────────────────────────────────────────────────────────── + +function Section({ + status, + items, + leagueId, +}: { + status: SectionStatus; + items: SportSeasonSummaryItem[]; + leagueId: string; +}) { + if (items.length === 0) return null; + + return ( +
+ +
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +// ─── 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 ( + + +
+
+ +
+

Sports

+
+
+ {allSeasonsHref && ( + + )} +
+
+ + +
+ {active.length > 0 && upcoming.length > 0 && ( +
+ )} +
+ {(active.length > 0 || upcoming.length > 0) && completed.length > 0 && ( +
+ )} +
+ + + ); +} diff --git a/app/components/league/StandingsPreview.stories.tsx b/app/components/league/StandingsPreview.stories.tsx new file mode 100644 index 0000000..6799c0e --- /dev/null +++ b/app/components/league/StandingsPreview.stories.tsx @@ -0,0 +1,265 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StandingsPreview } from "./StandingsPreview"; + +const meta: Meta = { + title: "League/StandingsPreview", + component: StandingsPreview, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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", + }, + ], + }, +}; diff --git a/app/components/league/StandingsPreview.tsx b/app/components/league/StandingsPreview.tsx new file mode 100644 index 0000000..f377b36 --- /dev/null +++ b/app/components/league/StandingsPreview.tsx @@ -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 ( +
+

+ {label} +

+
{children}
+
+ ); +} + +function StatDivider() { + return
; +} + +function RankChangeIndicator({ change }: { change: number }) { + if (change === 0) return null; + if (change > 0) { + return ( + + ▲{change} + + ); + } + return ( + + ▼{Math.abs(change)} + + ); +} + +function PointChangeIndicator({ change }: { change: number }) { + if (change >= 0) { + return ▲{Math.round(change)}; + } + return ( + + ▼{Math.abs(Math.round(change))} + + ); +} + +// ─── Row styles ─────────────────────────────────────────────────────────────── + +const ROW_RANK_CLASSES: Record = { + 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 */} +
+ +
+

{entry.teamName}

+ {entry.ownerName && ( +

{entry.ownerName}

+ )} +
+
+ + {/* Right: stats — second row on mobile */} +
+ + {entry.displayRank} + {entry.rankChange !== undefined && entry.rankChange !== 0 && ( + + )} + + + + + {Math.round(entry.points).toLocaleString("en-US")} + + {entry.pointChange !== undefined && entry.pointChange !== 0 && ( + + )} + +
+ + ); +} + +// ─── 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 ( + + +
+
+ +
+

Standings

+ {description && ( +

{description}

+ )} +
+
+ {fullStandingsHref && ( + + )} +
+
+ +
+ {entries.map((entry) => + entry.href ? ( + + + + ) : ( +
+ +
+ ) + )} +
+
+
+ ); +} diff --git a/app/components/league/TeamsPanel.stories.tsx b/app/components/league/TeamsPanel.stories.tsx new file mode 100644 index 0000000..d30863b --- /dev/null +++ b/app/components/league/TeamsPanel.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TeamsPanel } from "./TeamsPanel"; + +const meta: Meta = { + title: "League/TeamsPanel", + component: TeamsPanel, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/app/components/league/TeamsPanel.tsx b/app/components/league/TeamsPanel.tsx new file mode 100644 index 0000000..4ca927f --- /dev/null +++ b/app/components/league/TeamsPanel.tsx @@ -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; + /** 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 ( +
+
+ + + {hasDraftOrder ? "Draft Order" : "Teams"} + +
+ + {showProgressBar && ( +
+
+ + Filled + + + {claimedCount} + / {teams.length} + +
+
+
+
+
+ )} + +
+ {displayTeams.map((team, index) => { + const isOwned = team.ownerId === currentUserId; + const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; + return ( +
+ {hasDraftOrder && ( + + {index + 1} + + )} +

{team.name}

+

+ {team.ownerId + ? isOwned + ? You + : {ownerName || "Unknown"} + : + } +

+
+ ); + })} +
+
+ ); +} diff --git a/app/components/marketing/Footer.tsx b/app/components/marketing/Footer.tsx new file mode 100644 index 0000000..a50f889 --- /dev/null +++ b/app/components/marketing/Footer.tsx @@ -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 ( +
+ © 2026 Brackt. All rights reserved. + +
+ ); +} diff --git a/app/components/marketing/LandingPage.tsx b/app/components/marketing/LandingPage.tsx new file mode 100644 index 0000000..b044bdd --- /dev/null +++ b/app/components/marketing/LandingPage.tsx @@ -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(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 | null>(null); + const sportsSeq = useRef([]); + + 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 ( + + Every Sport. + + ); + } + + return ( + + {text} + + ); +} + +// ─── 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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {r3Ys.map((midY) => ( + + ))} + + + + {r3Ys.map((midY) => ( + + ))} + + + + {r1Ys.map((y) => ( + + + + + ))} + {r2Ys.map((midY, i) => ( + + + + + + ))} + {r3Ys.map((midY, i) => ( + + + + + + ))} + + + + + + ); +} + +// ─── 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 ( +
+ +
+ ); +} + +// ─── 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 ( +
+
+
+
+ {TICKER_ITEMS.map(({ sport, key }) => ( + + {sport} + + ))} +
+
+ ); +} + +// ─── 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( + + + +

{step.body}

+
+
+ ); + if (i < HOW_IT_WORKS_STEPS.length - 1) { + // Triangles reference #brackt-primary-gradient defined in (mounted in root.tsx) + howItWorksItems.push( +
+ + + +
+ ); + } + }); + + return ( +
+ {/* ── Hero ── */} +
+ {/* Dot-grid texture */} +
+ + {/* Bracket decorations */} +
+ + +
+
+ + +
+ + + + {/* Center content */} +
+

+ Fantasy + +

+ +

+ Draft teams across every league and sport, like the NFL, NHL, EPL, F1, and more. + Compete against your friends in a season-long league. +

+ +
+ + +
+ + +
+
+ + {/* ── How It Works ── */} +
+
+ {howItWorksItems} +
+
+ + {/* ── Features Grid ── */} +
+
+
+ {FEATURES.map(({ title, body }) => ( +
+

{title}

+

{body}

+
+ ))} +
+
+
+ + {/* ── Footer CTA ── */} +
+
+

+ Stop managing your league and start watching it. +

+

+ 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. +

+ +
+
+
+ ); +} diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx index 6e83d40..40747b7 100644 --- a/app/components/navbar.tsx +++ b/app/components/navbar.tsx @@ -1,12 +1,5 @@ import { useState } from "react"; import { Link } from "react-router"; -import { - NavigationMenu, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, - navigationMenuTriggerStyle, -} from "~/components/ui/navigation-menu"; import { Sheet, SheetContent, @@ -14,8 +7,9 @@ import { SheetTitle, SheetTrigger, } from "~/components/ui/sheet"; +import { Button } from "~/components/ui/button"; import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router"; -import { MenuIcon } from "lucide-react"; +import { HelpCircle, MenuIcon, Settings } from "lucide-react"; interface NavbarProps { isAdmin: boolean; @@ -27,51 +21,38 @@ export function Navbar({ isAdmin }: NavbarProps) { return (
- {/* Logo */} - - Brackt - + {/* Left: Logo + Nav links */} +
+ + Brackt + + +
- {/* Desktop Navigation */} - - - - - Home - - - - - How To Play - - - - - Rules - - - - - Support - - - {isAdmin && ( - - - Admin - - - )} - - - - {/* Desktop Auth */} -
+ {/* Desktop Right: Support + Admin icons + Auth */} +
+ + + + {isAdmin && ( + + + + )} - + @@ -79,19 +60,33 @@ export function Navbar({ isAdmin }: NavbarProps) {
- {/* Mobile Menu */} -
+ {/* Mobile: icons + Auth + Hamburger */} +
+ + + + {isAdmin && ( + + + + )} - + - + + + {label} + + +
+ +
+
+
+ +
+ {anim?.phase === "sliding" && ( +
+ +
+ )} +
+
+ + {thirdPlaceMatch && ( +
+
+ 3rd Place +
+ +
+ )} +
+ ); +} diff --git a/app/components/scoring/NbaBracketLayout.tsx b/app/components/scoring/NbaBracketLayout.tsx new file mode 100644 index 0000000..a10b841 --- /dev/null +++ b/app/components/scoring/NbaBracketLayout.tsx @@ -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; + ownershipMap: Map; + userParticipantIds: Set; + conferenceGroups: ConferenceGroup[]; + scoringRoundIdx: number; +} + +const DESIRED_CARD_HEIGHT = 112; +const CARD_GAP = 14; + +function splitMatchesByConference( + matchesByRound: Map, + group: ConferenceGroup +): Map { + const result = new Map(); + 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, 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 ── */} +
+ {conferenceGroups.map((group, gi) => { + const confRounds = conferenceRounds[gi]; + const confMatches = splitMatchesByConference(matchesByRound, group); + const height = bracketHeight(confMatches, confRounds); + + return ( +
+

+ {group.name} +

+ +
+ ); + })} + + {sharedRounds.length > 0 && ( +
+ +
+ )} +
+ + {/* ── Mobile: paginated across all rounds ── */} +
+ +
+ + ); +} diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index cd5238e..8dc1767 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -1,4 +1,3 @@ -import { Badge } from "~/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Table, @@ -10,13 +9,24 @@ import { } from "~/components/ui/table"; import { Trophy, Star } from "lucide-react"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; +import { GradientIcon } from "~/components/ui/GradientIcon"; +import { TeamAvatar } from "~/components/TeamAvatar"; +import { + BracketTreeView, + BracketTreePaginated, + type BracketMatch, + type BracketOwnership, +} from "./BracketTreeView"; +import { getBracketTemplate } from "~/lib/bracket-templates"; +import { NbaBracketLayout } from "./NbaBracketLayout"; +import { TabbedBracketLayout } from "./TabbedBracketLayout"; interface Participant { id: string; name: string; } -interface Match { +export interface Match { id: string; round: string; matchNumber: number; @@ -27,13 +37,14 @@ interface Match { isComplete: boolean; participant1Score: string | null; participant2Score: string | null; + isScoring?: boolean; participant1?: Participant | null; participant2?: Participant | null; winner?: Participant | null; loser?: Participant | null; } -interface TeamOwnership { +export interface TeamOwnership { participantId: string; teamName: string; teamId: string; @@ -43,14 +54,17 @@ interface TeamOwnership { interface PlayoffBracketProps { matches: Match[]; rounds: string[]; // Ordered list of round names (earliest first) - preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage) - participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant - partialScoreParticipantIds?: string[]; // Still-competing participants with provisional floor scores + bracketTemplateId?: string | null; + preEliminatedParticipants?: { id: string; name: string }[]; + participantPoints?: { participantId: string; points: number }[]; + partialScoreParticipantIds?: string[]; teamOwnerships?: TeamOwnership[]; userParticipantIds?: string[]; showOwnership?: boolean; title?: string; description?: string; + /** "bracket" = full bracket + tables (default); "rankings" = final rankings table only */ + mode?: "bracket" | "rankings"; } /** Group matches by round, sorted by matchNumber, in one pass. */ @@ -164,9 +178,29 @@ export function computeEliminatedByRound( return result; } +/** Find the index of the first round that has scoring matches. */ +function firstScoringRoundIdx(matchesByRound: Map, 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 = { + 1: "bg-yellow-500/10", + 2: "bg-white/[0.14]", + 3: "bg-orange-600/[0.08]", +}; + +function rankingRowCls(currentRank: number | undefined): string { + const podium = currentRank !== undefined ? RANKING_ROW_CLASSES[currentRank] : undefined; + return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${podium ?? "bg-white/[0.04]"}`; +} + export function PlayoffBracket({ matches, rounds, + bracketTemplateId = null, preEliminatedParticipants = [], participantPoints = [], partialScoreParticipantIds: _partialScoreParticipantIds = [], @@ -175,27 +209,23 @@ export function PlayoffBracket({ showOwnership = true, title = "Playoff Bracket", description, + mode = "bracket", }: PlayoffBracketProps) { const userParticipantSet = new Set(userParticipantIds); const ownershipMap = new Map(); teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o)); const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points])); - // Group matches once; reused for feeder map and rendering const matchesByRound = groupMatchesByRound(matches); - const feederMap = buildFeederMap(matchesByRound, rounds); + const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); + const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; - const getTbdLabel = (round: string, matchNumber: number, slot: "p1" | "p2") => { - const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`); - if (!feeder) return "TBD"; - return `Winner of ${feeder.round} M${feeder.matchNumber}`; - }; + const thirdPlaceRound = template?.rounds + .find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name)) + ?.name; - // Build elimination rankings: collect losers per round, then assign rank labels. - // In double-chance brackets (e.g. AFL), a participant may lose one round but - // win a later one — only count them as eliminated at their FINAL losing match. + // Build elimination rankings const losersByRound = new Map>(); - let _hasScore = false; let bracketWinner: Participant | null = null; const lastRound = rounds[rounds.length - 1]; @@ -204,7 +234,6 @@ export function PlayoffBracket({ : null; if (finalMatch?.winner) bracketWinner = finalMatch.winner; - // Compute which participants are eliminated in which round, handling double-chance. const eliminatedByRound = computeEliminatedByRound(matches, rounds); for (const match of matches) { @@ -215,7 +244,6 @@ export function PlayoffBracket({ match.loserId === match.participant1Id ? match.participant1Score : match.participant2Score; - if (loserScore) _hasScore = true; if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); losersByRound.get(match.round)?.push({ participant: match.loser, @@ -224,17 +252,13 @@ export function PlayoffBracket({ }); } - // Walk rounds latest→earliest to assign rank labels. - // Use TOTAL match count per round (not eliminated-so-far) so that partial scoring - // shows the correct eventual rank. E.g. R64 losers in NCAAM 68 always show T33 - // even while other R64 games are still pending. const allBracketParticipantIds = new Set(); for (const match of matches) { if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); } const rankedEntries: EliminatedEntry[] = []; - let nextRank = 2; // rank 1 = champion (even if not yet decided) + let nextRank = 2; for (let ri = rounds.length - 1; ri >= 0; ri--) { const roundName = rounds[ri]; const roundLosers = losersByRound.get(roundName) || []; @@ -248,7 +272,6 @@ export function PlayoffBracket({ nextRank += totalMatchesInRound; } - // Exclude pre-eliminated participants already ranked via bracket match losers const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); if (bracketWinner) rankedParticipantIds.add(bracketWinner.id); const filteredPreEliminated = preEliminatedParticipants.filter( @@ -257,7 +280,6 @@ export function PlayoffBracket({ const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0; - // Build participant lookup from match data for the "In Contention" table const participantMap = new Map(); for (const match of matches) { if (match.participant1) participantMap.set(match.participant1.id, match.participant1); @@ -268,17 +290,16 @@ export function PlayoffBracket({ .map((id) => participantMap.get(id)) .filter((p): p is Participant => p !== undefined); - // Hide participants with 0 points that nobody drafted — they add noise without value. const isDraftedOrScoring = (participantId: string) => ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; - // Hoist winner row lookups so we don't need an IIFE in JSX const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false; const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined; const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined; return (
+ {/* Header */}

{title}

{description && ( @@ -295,177 +316,54 @@ export function PlayoffBracket({
) : (
- {rounds.map((round) => { - const roundMatches = matchesByRound.get(round) || []; - if (roundMatches.length === 0) return null; - - return ( -
-
-

- {round} -

-
- - {roundMatches.length}{" "} - {roundMatches.length === 1 ? "match" : "matches"} - -
- -
- {roundMatches.map((match) => { - const p1IsWinner = - match.isComplete && match.winnerId === match.participant1Id; - const p2IsWinner = - match.isComplete && match.winnerId === match.participant2Id; - const p1IsLoser = - match.isComplete && match.loserId === match.participant1Id; - const p2IsLoser = - match.isComplete && match.loserId === match.participant2Id; - - const p1Name = - match.participant1?.name || - (match.participant1Id - ? "TBD" - : getTbdLabel(round, match.matchNumber, "p1")); - const p2Name = - match.participant2?.name || - (match.participant2Id - ? "TBD" - : getTbdLabel(round, match.matchNumber, "p2")); - - const p1IsTbd = !match.participant1Id; - const p2IsTbd = !match.participant2Id; - - const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id ?? ""); - const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id ?? ""); - const matchHasOwned = p1IsOwned || p2IsOwned; - - const p1Ownership = - showOwnership && match.participant1Id - ? ownershipMap.get(match.participant1Id) || null - : null; - const p2Ownership = - showOwnership && match.participant2Id - ? ownershipMap.get(match.participant2Id) || null - : null; - - return ( -
- {/* Match label row */} -
- - M{match.matchNumber} - - {match.isComplete && ( - - Done - - )} -
- - {/* Participants: left vs right (stacks on mobile) */} -
- {/* Participant 1 — left-aligned */} -
-
- {p1IsOwned && !p1IsWinner && ( - - )} - {p1IsWinner && ( - - )} - - {p1Name} - -
- {!p1IsTbd && p1Ownership && ( -
- -
- )} -
- - {/* Center: scores + vs */} -
- {match.participant1Score ? ( - <> - - {parseFloat(match.participant1Score)} - - - vs - - - {match.participant2Score - ? parseFloat(match.participant2Score) - : "—"} - - - ) : ( - vs - )} -
- - {/* Participant 2 — right-aligned on sm+, left-aligned on mobile */} -
-
- - {p2Name} - - {p2IsWinner && ( - - )} - {p2IsOwned && !p2IsWinner && ( - - )} -
- {!p2IsTbd && p2Ownership && ( -
- -
- )} -
-
-
- ); - })} -
+ {mode === "bracket" && (template?.phases ? ( + } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + phases={template.phases} + scoringRoundIdx={scoringRoundIdx} + /> + ) : template?.conferenceGroups ? ( + } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + conferenceGroups={template.conferenceGroups} + scoringRoundIdx={scoringRoundIdx} + /> + ) : ( + <> + {/* ── Desktop ── */} +
+ } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + thirdPlaceRound={thirdPlaceRound} + />
- ); - })} - {/* In Contention */} - {activeParticipants.length > 0 && ( + {/* ── Mobile ── */} +
+ } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + firstScoringRoundIdx={scoringRoundIdx} + thirdPlaceRound={thirdPlaceRound} + /> +
+ + ))} + + {/* ── In Contention ── */} + {mode === "bracket" && activeParticipants.length > 0 && ( @@ -525,134 +423,127 @@ export function PlayoffBracket({ )} - {/* Final Rankings / Eliminated Teams */} - {showRankings && ( - - - - - {bracketWinner ? "Final Rankings" : "Eliminated Teams"} - + {/* ── Final Rankings ── */} + {mode === "rankings" && showRankings && ( + + +
+ +

+ {bracketWinner ? "Final Rankings" : "Eliminated Teams"} +

+
- - - - - Rank - Participant - {showOwnership && ( - Drafted By - )} - {pointsMap.size > 0 && ( - Pts - )} - - - - {bracketWinner && ( - - - - - #1 - - - - {bracketWinner.name} - {winnerIsOwned && ( - + +
+ {bracketWinner && ( +
+
+ +
+

+ {bracketWinner.name} + {winnerIsOwned && } +

+ {winnerOwnership?.ownerName && ( +

{winnerOwnership.ownerName}

)} - - {showOwnership && ( - - {winnerOwnership ? ( - - ) : ( - - - )} - - )} +
+
+
+
+

Rank

+ 1 +
{pointsMap.size > 0 && ( - - {winnerPts ?? "—"} - + <> +
+
+

Points

+ {winnerPts ?? 0} +
+ )} - - )} +
+
+ )} - {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => { - const isOwned = userParticipantSet.has(entry.participant.id); - const pts = pointsMap.get(entry.participant.id); - return ( - - - {entry.rankLabel} - - - {entry.participant.name} - {isOwned && ( - + {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => { + const isOwned = userParticipantSet.has(entry.participant.id); + const pts = pointsMap.get(entry.participant.id); + const numRank = parseInt(entry.rankLabel.replace("T", ""), 10); + return ( +
+
+ +
+

+ {entry.participant.name} + {isOwned && } +

+ {entry.ownership?.ownerName && ( +

{entry.ownership.ownerName}

)} - - {showOwnership && ( - - {entry.ownership ? ( - - ) : ( - - - )} - - )} +
+
+
+
+

Rank

+ {entry.rankLabel} +
{pointsMap.size > 0 && ( - - {pts ?? "—"} - + <> +
+
+

Points

+ {pts ?? 0} +
+ )} - - ); - })} +
+
+ ); + })} - {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { - const isOwned = userParticipantSet.has(p.id); - const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; - const pts = pointsMap.get(p.id); - return ( - - - - {p.name} - {isOwned && ( - + {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { + const isOwned = userParticipantSet.has(p.id); + const ownership = ownershipMap.get(p.id); + const pts = pointsMap.get(p.id); + return ( +
+
+ +
+

+ {p.name} + {isOwned && } +

+ {ownership?.ownerName && ( +

{ownership.ownerName}

)} - - {showOwnership && ( - - {ownership ? ( - - ) : ( - - - )} - - )} - {pointsMap.size > 0 && ( - - {pts ?? "—"} - - )} - - ); - })} - -
+
+
+ {pointsMap.size > 0 && ( +
+
+

Points

+ {pts ?? 0} +
+
+ )} +
+ ); + })} +
)} @@ -661,3 +552,4 @@ export function PlayoffBracket({
); } + diff --git a/app/components/scoring/SportSeasonDisplay.tsx b/app/components/scoring/SportSeasonDisplay.tsx index aaed3ab..490194c 100644 --- a/app/components/scoring/SportSeasonDisplay.tsx +++ b/app/components/scoring/SportSeasonDisplay.tsx @@ -92,10 +92,12 @@ interface SportSeasonDisplayProps { scoringPattern: ScoringPattern; sportSeasonName: string; sportName: string; + bracketMode?: "bracket" | "rankings"; // Playoff data playoffMatches?: Match[]; playoffRounds?: string[]; + bracketTemplateId?: string | null; preEliminatedParticipants?: { id: string; name: string }[]; participantPoints?: { participantId: string; points: number }[]; partialScoreParticipantIds?: string[]; @@ -122,8 +124,10 @@ export function SportSeasonDisplay({ scoringPattern, sportSeasonName, sportName: _sportName, + bracketMode = "bracket", playoffMatches = [], playoffRounds = [], + bracketTemplateId = null, preEliminatedParticipants = [], participantPoints = [], partialScoreParticipantIds = [], @@ -147,6 +151,7 @@ export function SportSeasonDisplay({ ); diff --git a/app/components/scoring/TabbedBracketLayout.tsx b/app/components/scoring/TabbedBracketLayout.tsx new file mode 100644 index 0000000..45440f0 --- /dev/null +++ b/app/components/scoring/TabbedBracketLayout.tsx @@ -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; + ownershipMap: Map; + userParticipantIds: Set; + phases: BracketPhase[]; + scoringRoundIdx: number; +} + +const CARD_H = 112; +const CARD_GAP = 14; + +function groupMatches( + matchesByRound: Map, + group: ConferenceGroup +): Map { + const out = new Map(); + 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, 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; + userParticipantIds: Set; +} + +function PlayInColumn({ + label, + description, + match, + ownershipMap, + userParticipantIds, +}: PlayInColumnProps) { + return ( +
+

+ {label} +

+ {match ? ( + + ) : ( +
+ )} +

{description}

+
+ ); +} + +interface PlayInLayoutProps { + matchesByRound: Map; + ownershipMap: Map; + userParticipantIds: Set; +} + +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 ( +
+ {conferences.map((conf) => ( +
+

+ {conf.name} +

+
+ + + +
+
+ ))} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function TabbedBracketLayout({ + rounds, + matchesByRound, + ownershipMap, + userParticipantIds, + phases, + scoringRoundIdx, +}: TabbedBracketLayoutProps) { + return ( +
+ {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 ( +
+

1 ? "text-muted-foreground" : "hidden" + )}> + {phase.name} +

+ + {/* Desktop */} +
+ {phase.layout === "play-in" ? ( + + ) : phase.groups ? ( +
+ {phase.groups.map((group) => { + const gMatches = groupMatches(matchesByRound, group); + const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); + return ( +
+

+ {group.name} +

+ +
+ ); + })} + {sharedRounds.length > 0 && ( + + )} +
+ ) : ( + + )} +
+ + {/* Mobile */} +
+ {phase.layout === "play-in" ? ( + + ) : ( + = 0 ? phaseFirstScoringIdx : undefined} + /> + )} +
+
+ ); + })} +
+ ); +} diff --git a/app/components/sport-season/RegularSeasonStandings.tsx b/app/components/sport-season/RegularSeasonStandings.tsx index c941a85..202c136 100644 --- a/app/components/sport-season/RegularSeasonStandings.tsx +++ b/app/components/sport-season/RegularSeasonStandings.tsx @@ -41,7 +41,6 @@ interface Props { teamOwnerships: Record; userParticipantIds: string[]; showOtLosses?: boolean; - participantEvs?: Record; /** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */ playoffSpots?: number; /** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */ @@ -223,19 +222,15 @@ function StandingsTable({ teamOwnerships, userParticipantIds, showOtLosses, - participantEvs, - hasEvs, }: { sections: TableSection[]; teamOwnerships: Record; userParticipantIds: string[]; showOtLosses: boolean; - participantEvs: Record; - hasEvs: boolean; }) { - // # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr [PROJ] + // # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr // OTL and PTS are both shown for hockey (showOtLosses = true) - const totalCols = 10 + (showOtLosses ? 2 : 0) + (hasEvs ? 1 : 0); + const totalCols = 10 + (showOtLosses ? 2 : 0); return (
@@ -254,7 +249,6 @@ function StandingsTable({ L10 STK Mgr - {hasEvs && PROJ} @@ -299,7 +293,6 @@ function StandingsTable({ const isUserTeam = userParticipantIds.includes(row.participantId); const ownership = teamOwnerships[row.participantId]; - const ev = participantEvs[row.participantId]; sectionRows.push( — )} - {hasEvs && ( - - {ev !== null ? ( - - {parseFloat(ev).toFixed(1)} - - ) : ( - - )} - - )} ); }); @@ -399,13 +381,11 @@ export function RegularSeasonStandings({ teamOwnerships, userParticipantIds, showOtLosses = false, - participantEvs = {}, playoffSpots = 8, displayMode = "flat", }: Props) { if (standings.length === 0) return null; - const hasEvs = Object.keys(participantEvs).length > 0; const lastSyncedAt = standings .map((s) => s.syncedAt) .filter(Boolean) @@ -419,7 +399,7 @@ export function RegularSeasonStandings({ ? buildMlbSections(standings) : buildFlatSections(standings, playoffSpots); - const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs }; + const tableProps = { teamOwnerships, userParticipantIds, showOtLosses }; return ( diff --git a/app/components/sport-season/UpcomingEventsCard.stories.tsx b/app/components/sport-season/UpcomingEventsCard.stories.tsx new file mode 100644 index 0000000..1f76202 --- /dev/null +++ b/app/components/sport-season/UpcomingEventsCard.stories.tsx @@ -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 = { + title: "Sport Season/EventRow", + component: EventRow, + parameters: { layout: "padded" }, +}; + +export default eventRowMeta; +type EventRowStory = StoryObj; + +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, + }, +}; + diff --git a/app/components/sport-season/UpcomingEventsCard.tsx b/app/components/sport-season/UpcomingEventsCard.tsx new file mode 100644 index 0000000..e965944 --- /dev/null +++ b/app/components/sport-season/UpcomingEventsCard.tsx @@ -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(); + + 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 ( +
+ ); + } + return ( +
+ ); +} + +function ParticipantsByLeague({ + leagues, + isAllCompete, + showLeagueAvatar = true, +}: { + leagues: LeagueParticipants[]; + isAllCompete: boolean; + showLeagueAvatar?: boolean; +}) { + return ( +
+ {leagues.map((league) => { + const content = + isAllCompete && league.participants.length > 1 ? ( + + {league.participants.length} of your picks + + ) : ( + + {league.participants.slice(0, 3).map((p) => ( + + {p.name} + + ))} + {league.participants.length > 3 && ( + + +{league.participants.length - 3} more + + )} + + ); + + if (!showLeagueAvatar) { + return
{content}
; + } + + return ( +
+ + {content} +
+ ); + })} +
+ ); +} + +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 = ( +
+ {/* Timeline spine */} +
+ + {!isLast &&
} +
+ + {/* Content */} +
+ {/* Left: date + name */} +
+
+ + {dateStr ?? "TBD"} + + {timeStr && ( + <> + | + {timeStr} + + )} +
+
+ {displayName} +
+
+ {event.sportName} +
+
+ + {/* Divider — hidden on mobile where layout is stacked */} +
+ + {/* Right: participants per league */} +
+ +
+
+
+ ); + + if (event.sportsSeasonPageUrl) { + return ( + + {content} + + ); + } + return
{content}
; +} + +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 ( +
+ {/* Header */} +
+
+ + {title} +
+ {viewAllUrl && ( + + View All + + + )} +
+ + {/* Timeline */} + {grouped.length === 0 ? ( +

+ {emptyMessage ?? "No upcoming events in the next 30 days."} +

+ ) : ( +
+ {displayedEvents.map((event, index) => ( + + ))} + {viewAllUrl && hiddenCount > 0 && ( + + View all {grouped.length} upcoming events + + + )} +
+ )} +
+ ); +} diff --git a/app/components/sport-season/UpcomingEventsCardFull.stories.tsx b/app/components/sport-season/UpcomingEventsCardFull.stories.tsx new file mode 100644 index 0000000..ba4b68b --- /dev/null +++ b/app/components/sport-season/UpcomingEventsCardFull.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UpcomingEventsCard } from "./UpcomingEventsCard"; +import type { CalendarPanelEvent } from "./UpcomingCalendarPanel"; + +const meta: Meta = { + title: "Sport Season/UpcomingEventsCard", + component: UpcomingEventsCard, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/app/components/standings/PointProgressionChart.stories.tsx b/app/components/standings/PointProgressionChart.stories.tsx new file mode 100644 index 0000000..7d71142 --- /dev/null +++ b/app/components/standings/PointProgressionChart.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PointProgressionChart } from "./PointProgressionChart"; +import type { ChartDataPoint } from "./PointProgressionChart"; + +const meta: Meta = { + 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; + +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, + }, +}; diff --git a/app/components/standings/PointProgressionChart.tsx b/app/components/standings/PointProgressionChart.tsx index ec867bc..e3a2d53 100644 --- a/app/components/standings/PointProgressionChart.tsx +++ b/app/components/standings/PointProgressionChart.tsx @@ -1,12 +1,62 @@ -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card"; +import { useState } from "react"; + +interface LegendPayload { + value: string; + color: string; +} + +interface CustomLegendProps { + payload: LegendPayload[]; + onHover: (dataKey: string | null) => void; + onLeave: () => void; +} + +function CustomLegend({ payload, onHover, onLeave }: CustomLegendProps) { + return ( +
+ {payload.map((entry) => ( +
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(); + } + }} + > +
+ ))} +
+ ); +} interface Team { id: string; name: string; } -interface ChartDataPoint { +export interface ChartDataPoint { date: string; [teamName: string]: string | number; } @@ -36,6 +86,8 @@ function formatDate(dateStr: string) { } export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) { + const [hoveredLine, setHoveredLine] = useState(null); + if (chartData.length === 0) { return ( @@ -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 ( @@ -62,46 +121,45 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar - - - - - - formatDate(label as string)} - formatter={(value: number) => [value.toFixed(2), '']} - /> - - {teams.map((team, index) => ( - - ))} - - +
+
+ + + + + + {teams.map((team, index) => ( + + ))} + + +
+ ({ value: team.name, color: COLORS[teams.findIndex(t => t.id === team.id) % COLORS.length] }))} + onHover={setHoveredLine} + onLeave={() => setHoveredLine(null)} + /> +
); diff --git a/app/components/standings/RecentScoresCard.stories.tsx b/app/components/standings/RecentScoresCard.stories.tsx new file mode 100644 index 0000000..ae58887 --- /dev/null +++ b/app/components/standings/RecentScoresCard.stories.tsx @@ -0,0 +1,192 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RecentScoresCard } from "./RecentScoresCard"; +import type { ScoreEventEntry } from "./RecentScoresCard"; + +const meta: Meta = { + title: "Standings/RecentScoresCard", + component: RecentScoresCard, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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" }, + ], + }, + ], + }, +}; diff --git a/app/components/standings/RecentScoresCard.tsx b/app/components/standings/RecentScoresCard.tsx new file mode 100644 index 0000000..509580c --- /dev/null +++ b/app/components/standings/RecentScoresCard.tsx @@ -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 ( +
+ ); +} + +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 ( +
+ {/* Timeline spine */} +
+ + {!isLast &&
} +
+ + {/* Content row: points box + text */} +
+ {/* Points box — square, no rounded corners */} +
+ +{displayPts} +
+ + {/* Text content */} +
+ {/* Date */} +
+ {formatDistanceToNow(new Date(event.occurredAt), { addSuffix: true })} +
+ + {/* Manager name */} +
+ {managerName} +
+ + {/* Sport · participant name(s) */} +
+ {[ + event.sportName, + event.participants.length > 0 + ? event.participants.map((p) => p.name).join(", ") + : null, + ] + .filter(Boolean) + .join(" · ")} +
+
+
+
+ ); +} + +export function RecentScoresCard({ events }: RecentScoresCardProps) { + return ( +
+ {/* Header */} +
+ + Recent Scores +
+ + {/* Timeline */} + {events.length === 0 ? ( +

+ No recent scores yet — they'll appear here as events complete. +

+ ) : ( +
+ {events.map((event, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/components/ui/BracktGradients.tsx b/app/components/ui/BracktGradients.tsx new file mode 100644 index 0000000..d3e10e1 --- /dev/null +++ b/app/components/ui/BracktGradients.tsx @@ -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 ( + + + {/* 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. */} + + + + + + + ); +} diff --git a/app/components/ui/GradientIcon.tsx b/app/components/ui/GradientIcon.tsx new file mode 100644 index 0000000..8fd7db6 --- /dev/null +++ b/app/components/ui/GradientIcon.tsx @@ -0,0 +1,30 @@ +import type { ComponentType } from "react"; +import type { LucideProps } from "lucide-react"; + +interface GradientIconProps extends Omit { + icon: ComponentType; + /** Defaults to the primary brand gradient (green → cyan). */ + gradientId?: string; +} + +/** + * Renders any Lucide icon with its stroke set to a named SVG gradient. + * Requires to be mounted somewhere in the document. + * + * Usage: + * + * + */ +export function GradientIcon({ + icon: Icon, + gradientId = "brackt-primary-gradient", + style, + ...props +}: GradientIconProps) { + return ( + + ); +} diff --git a/app/components/ui/SectionCardHeader.tsx b/app/components/ui/SectionCardHeader.tsx new file mode 100644 index 0000000..c983657 --- /dev/null +++ b/app/components/ui/SectionCardHeader.tsx @@ -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; + 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 ( + +
+
+ +

{title}

+
+ {right} +
+
+ ); +} diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx index 1c89dc7..fc70709 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -5,11 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "app/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-xs font-semibold uppercase tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: + "bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] text-primary-foreground hover:opacity-90", destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: diff --git a/app/components/ui/card.tsx b/app/components/ui/card.tsx index 0ebaf40..eda5231 100644 --- a/app/components/ui/card.tsx +++ b/app/components/ui/card.tsx @@ -1,20 +1,38 @@ import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" import { cn } from "app/lib/utils" -function Card({ className, ...props }: React.ComponentProps<"div">) { +const cardVariants = cva("", { + variants: { + variant: { + default: + "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", + tile: + "flex rounded-lg pr-1 [background:linear-gradient(to_bottom,#adf661,#2ce1c1)]", + }, + }, + defaultVariants: { + variant: "default", + }, +}) + +function Card({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { return (
) } +export { cardVariants } + function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
w[0].toUpperCase()) + .join("") || "?"; + const color = AVATAR_COLORS[hashName(teamName) % AVATAR_COLORS.length]; const avatar = (
- {initial} + {initials}
); diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index 6deb0b6..eba2be8 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -61,6 +61,37 @@ export interface BracketRegion { playIns: BracketPlayIn[]; } +/** + * Defines a named conference or sub-bracket group within a bracket. + * Used to split matches into East/West (NBA) or regional groups (NCAA) for display. + */ +export interface ConferenceGroup { + /** Display name, e.g. "Eastern Conference" */ + name: string; + /** + * Maps round name → the match numbers (1-based) that belong to this conference. + * Rounds not listed here are either shared (Finals) or not applicable. + */ + roundMatchNumbers: Record; +} + +/** + * A named tab/section within a bracket display. + * Either shows a simple list of rounds, or a set of conference/regional sub-brackets. + */ +export interface BracketPhase { + /** Tab label */ + name: string; + /** Simple ordered list of round names to show in this tab */ + rounds?: string[]; + /** Regional/conference sub-groups to show stacked in this tab */ + groups?: ConferenceGroup[]; + /** Rounds rendered after groups (e.g. Final Four, Championship) */ + sharedRounds?: string[]; + /** Custom rendering layout for special phases */ + layout?: "play-in"; +} + export interface BracketTemplate { /** Unique identifier for this template */ id: string; @@ -88,6 +119,17 @@ export interface BracketTemplate { * Length must equal totalTeams. */ participantLabels?: string[]; + /** + * Optional conference/group split for brackets with parallel sub-brackets (NBA). + * When present, the UI renders each group as a separate sub-bracket. + * Rounds not listed in any group's roundMatchNumbers are treated as shared (e.g., Finals). + */ + conferenceGroups?: ConferenceGroup[]; + /** + * Optional tabbed phase display (NCAA). + * When present, the UI renders a tab switcher with each phase as a section. + */ + phases?: BracketPhase[]; } /** All seed numbers in a standard 16-team regional bracket (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 * Only Elite Eight and beyond score points per Q18 * - * 2026 region config: + * Region config (year-specific — update each March when the First Four matchups are announced): * East — 16 direct seeds, no play-ins * South — 15 direct seeds, 16-seed play-in * West — 15 direct seeds, 11-seed play-in @@ -474,6 +516,55 @@ export const NCAA_68: BracketTemplate = { ], }, ], + // Two-tab display: First Four → Bracket (4 regional sub-brackets + Final Four) + phases: [ + { + name: "First Four", + rounds: ["First Four"], + }, + { + name: "Bracket", + groups: [ + { + name: "East Region", + roundMatchNumbers: { + "Round of 64": [1, 2, 3, 4, 5, 6, 7, 8], + "Round of 32": [1, 2, 3, 4], + "Sweet Sixteen": [1, 2], + "Elite Eight": [1], + }, + }, + { + name: "South Region", + roundMatchNumbers: { + "Round of 64": [9, 10, 11, 12, 13, 14, 15, 16], + "Round of 32": [5, 6, 7, 8], + "Sweet Sixteen": [3, 4], + "Elite Eight": [2], + }, + }, + { + name: "West Region", + roundMatchNumbers: { + "Round of 64": [17, 18, 19, 20, 21, 22, 23, 24], + "Round of 32": [9, 10, 11, 12], + "Sweet Sixteen": [5, 6], + "Elite Eight": [3], + }, + }, + { + name: "Midwest Region", + roundMatchNumbers: { + "Round of 64": [25, 26, 27, 28, 29, 30, 31, 32], + "Round of 32": [13, 14, 15, 16], + "Sweet Sixteen": [7, 8], + "Elite Eight": [4], + }, + }, + ], + sharedRounds: ["Final Four", "Championship"], + }, + ], }; /** @@ -767,6 +858,17 @@ export const NBA_20: BracketTemplate = { "West 1", "West 2", "West 3", "West 4", "West 5", "West 6", "West 7", "West 8", "West 9", "West 10", ], + phases: [ + { + name: "Play-In", + rounds: ["Play-In Round 1", "Play-In Round 2"], + layout: "play-in" as const, + }, + { + name: "Playoffs", + rounds: ["First Round", "Conference Semifinals", "Conference Finals", "NBA Finals"], + }, + ], }; /** diff --git a/app/lib/brand.ts b/app/lib/brand.ts new file mode 100644 index 0000000..66507e4 --- /dev/null +++ b/app/lib/brand.ts @@ -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"; diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts index 091b1dd..5fb3b7f 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -1,6 +1,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; +import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules"; export async function createDraftPick(data: { seasonId: string; @@ -103,6 +104,129 @@ export async function getDraftedParticipantsBySportsSeason( return map; } +export interface DraftedParticipantWithPoints { + id: string; + name: string; + /** Fantasy league points earned (position → scoring rules). null = no result yet. */ + earnedPoints: number | null; + /** Accumulated qualifying points for qualifying_points sports. null for other sports. */ + currentQP: number | null; +} + +/** + * Get a team's drafted participants with their current earned points, grouped by + * sports season. Used for the league home summary card. + * + * - playoff_bracket / season_standings: earnedPoints = finalPosition → scoring rules + * - qualifying_points (active): currentQP = accumulated QP from participantQualifyingTotals + * - qualifying_points (finalized): earnedPoints = finalPosition → scoring rules (participantResults written) + * - No EV / projected points — actual earned only. + */ +export async function getDraftedParticipantsWithPoints( + teamId: string, + seasonId: string, + providedDb?: ReturnType +): Promise> { + 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(); + const qpSeasonIds = new Set(); + const qpParticipantIds = new Set(); + + 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(); + 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(); // 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(); + + for (const pick of picks) { + const { id, name, sportsSeasonId } = pick.participant; + const pattern = pick.participant.sportsSeason.scoringPattern; + const resultRow = pick.participant.results[0] ?? null; + + let earnedPoints: number | null = null; + let currentQP: number | null = null; + + if (pattern === "qualifying_points" && (resultRow?.finalPosition === null || resultRow?.finalPosition === undefined)) { + // Active QP season: show accumulated qualifying points + currentQP = qpMap.get(id) ?? null; + } else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) { + // Finalized result for any pattern (including finalized QP seasons) + earnedPoints = pattern === "playoff_bracket" + ? calculateBracketPoints( + resultRow.finalPosition, + scoringRules, + bracketTemplateMap.get(sportsSeasonId) ?? null + ) + : calculateFantasyPoints(resultRow.finalPosition, scoringRules); + } + + const arr = result.get(sportsSeasonId) ?? []; + result.set(sportsSeasonId, arr); + arr.push({ id, name, earnedPoints, currentQP }); + } + + return result; +} + export async function deleteAllDraftPicks(seasonId: string) { const db = database(); await db diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 48ed86f..4e36e43 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -9,6 +9,7 @@ import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; import { doesLoserAdvance } from "~/models/playoff-match"; import { getUserDisplayName } from "~/models/user"; import { createDailySnapshot } from "~/models/standings"; +import { recordMatchScoreEvents } from "~/models/team-score-events"; import { logger } from "~/lib/logger"; /** @@ -393,6 +394,7 @@ export async function processMatchResult( if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); } + // Non-scoring round wins are not surfaced in the Recent Scores feed. } else { const config = getRoundConfig(round, bracketTemplateId); @@ -402,15 +404,37 @@ export async function processMatchResult( `[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.` ); await upsertParticipantResult(loserId, sportsSeasonId, 0, db); - await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); + const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); + if (oldFloor !== null && matchId && eventId) { + await recordMatchScoreEvents( + { participantId: winnerId, sportsSeasonId, oldFloor, newFloor: 5, + bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, + db + ); + } } else if (config.winnerFloor === null) { // Finalization round: winner and loser both get their exact positions. await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db); - await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db); + const winnerPosition = config.winnerPosition ?? 1; + const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, winnerPosition, db); + if (oldFloor !== null && matchId && eventId) { + await recordMatchScoreEvents( + { participantId: winnerId, sportsSeasonId, oldFloor, newFloor: winnerPosition, + bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, + db + ); + } } else { // All other scoring rounds: loser gets final (or provisional) position, winner gets floor. await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial); - await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true); + const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true); + if (oldFloor !== null && matchId && eventId) { + await recordMatchScoreEvents( + { participantId: winnerId, sportsSeasonId, oldFloor, newFloor: config.winnerFloor, + bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, + db + ); + } } } @@ -431,11 +455,15 @@ export async function processMatchResult( } /** - * Helper to upsert participant result + * Helper to upsert participant result. * * isPartialScore=true means the participant is still alive and this placement * is their guaranteed minimum floor — it will be replaced as they advance or * when they are finally eliminated. + * + * Returns the previous finalPosition (or 0 if this is a new row), so callers + * can compute exact point deltas. Returns null when the update was skipped + * (i.e. trying to set a provisional floor on an already-finalized participant). */ async function upsertParticipantResult( participantId: string, @@ -443,7 +471,7 @@ async function upsertParticipantResult( finalPosition: number, db: ReturnType, isPartialScore = false -): Promise { +): Promise { const existing = await db.query.participantResults.findFirst({ where: and( eq(schema.participantResults.participantId, participantId), @@ -455,7 +483,7 @@ async function upsertParticipantResult( // Never un-finalize: if the row is already finalized (isPartialScore=false), // a call with isPartialScore=true must not overwrite it (e.g. a re-run of // processPlayoffEvent after a manual finalization). - if (!existing.isPartialScore && isPartialScore) return; + if (!existing.isPartialScore && isPartialScore) return null; await db .update(schema.participantResults) @@ -465,6 +493,7 @@ async function upsertParticipantResult( updatedAt: new Date(), }) .where(eq(schema.participantResults.id, existing.id)); + return existing.finalPosition ?? 0; } else { await db.insert(schema.participantResults).values({ participantId, @@ -472,6 +501,7 @@ async function upsertParticipantResult( finalPosition, isPartialScore, }); + return 0; } } diff --git a/app/models/team-score-events.ts b/app/models/team-score-events.ts new file mode 100644 index 0000000..ad76b2a --- /dev/null +++ b/app/models/team-score-events.ts @@ -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 +): Promise { + 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 +): Promise { + 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( + 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 +): Promise { + 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(); + 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), + })); +} diff --git a/app/root.tsx b/app/root.tsx index 04fd482..2040ee1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -16,6 +16,8 @@ import { NavigationProgress } from "~/components/NavigationProgress"; import { ClerkProvider } from "@clerk/react-router"; import { dark } from "@clerk/themes"; import { Toaster } from "~/components/ui/sonner"; +import { BracktGradients } from "~/components/ui/BracktGradients"; +import { Footer } from "~/components/marketing/Footer"; import { isUserAdminByClerkId } from "~/models/user"; import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react"; @@ -35,11 +37,11 @@ export async function loader(args: Route.LoaderArgs) { } export const links: Route.LinksFunction = () => [ - { rel: "icon", href: "/favicon.ico", sizes: "48x48" }, - { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" }, - { rel: "icon", href: "/favicon-96x96.png", type: "image/png", sizes: "96x96" }, - { rel: "apple-touch-icon", href: "/apple-touch-icon.png" }, - { rel: "manifest", href: "/site.webmanifest" }, + { rel: "icon", href: "/favicon.ico?v=20260402", sizes: "48x48" }, + { rel: "icon", href: "/favicon.svg?v=20260402", type: "image/svg+xml" }, + { rel: "icon", href: "/favicon-96x96.png?v=20260402", type: "image/png", sizes: "96x96" }, + { rel: "apple-touch-icon", href: "/apple-touch-icon.png?v=20260402" }, + { rel: "manifest", href: "/site.webmanifest?v=20260402" }, { rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", @@ -48,7 +50,7 @@ export const links: Route.LinksFunction = () => [ }, { rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + href: "https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap", }, ]; @@ -62,6 +64,7 @@ export function Layout({ children }: { children: React.ReactNode }) { + {children} @@ -81,9 +84,12 @@ export default function App({ loaderData }: Route.ComponentProps) { {isDraftRoute ? ( ) : ( -
- -
+ <> +
+ +
+