diff --git a/app/models/commissioner.ts b/app/models/commissioner.ts
index 6d7a55b..e17a558 100644
--- a/app/models/commissioner.ts
+++ b/app/models/commissioner.ts
@@ -50,6 +50,16 @@ export async function isCommissioner(
return !!commissioner;
}
+export async function countCommissionersByLeagueId(
+ leagueId: string
+): Promise {
+ const db = database();
+ const commissioners = await db.query.commissioners.findMany({
+ where: eq(schema.commissioners.leagueId, leagueId),
+ });
+ return commissioners.length;
+}
+
export async function deleteCommissioner(id: string): Promise {
const db = database();
await db.delete(schema.commissioners).where(eq(schema.commissioners.id, id));
diff --git a/app/models/league.ts b/app/models/league.ts
index 7f92e8b..50537e0 100644
--- a/app/models/league.ts
+++ b/app/models/league.ts
@@ -71,23 +71,38 @@ export async function findLeaguesWithActiveSeasonsByUserId(
): Promise {
const db = database();
- // Query to find all leagues where user has a team in the current season
- const leagues = await db
- .selectDistinct({
- id: schema.leagues.id,
- name: schema.leagues.name,
- createdBy: schema.leagues.createdBy,
- currentSeasonId: schema.leagues.currentSeasonId,
- isPublicDraftBoard: schema.leagues.isPublicDraftBoard,
- createdAt: schema.leagues.createdAt,
- updatedAt: schema.leagues.updatedAt,
- })
+ const leagueFields = {
+ id: schema.leagues.id,
+ name: schema.leagues.name,
+ createdBy: schema.leagues.createdBy,
+ currentSeasonId: schema.leagues.currentSeasonId,
+ isPublicDraftBoard: schema.leagues.isPublicDraftBoard,
+ createdAt: schema.leagues.createdAt,
+ updatedAt: schema.leagues.updatedAt,
+ };
+
+ // Leagues where user has a team in the current season
+ const leaguesWithTeam = await db
+ .select(leagueFields)
.from(schema.leagues)
.innerJoin(schema.teams, eq(schema.teams.seasonId, schema.leagues.currentSeasonId))
- .where(eq(schema.teams.ownerId, userId))
- .orderBy(desc(schema.leagues.createdAt));
+ .where(eq(schema.teams.ownerId, userId));
- return leagues;
+ // Leagues where user is a commissioner (with or without a team)
+ const leaguesAsCommissioner = await db
+ .select(leagueFields)
+ .from(schema.leagues)
+ .innerJoin(schema.commissioners, eq(schema.commissioners.leagueId, schema.leagues.id))
+ .where(eq(schema.commissioners.userId, userId));
+
+ // Deduplicate by id in case user is both a commissioner and has a team, then sort
+ const leagueMap = new Map();
+ for (const league of [...leaguesWithTeam, ...leaguesAsCommissioner]) {
+ leagueMap.set(league.id, league);
+ }
+ return [...leagueMap.values()].sort(
+ (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
+ );
}
/**
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index e2dac3b..4674a79 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -92,9 +92,9 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{leagues.length === 0 ? (
- No Active Leagues
+ No Leagues
- You don't have any teams in leagues with active seasons yet
+ You aren't a member of any leagues yet
diff --git a/app/routes/how-to-play.tsx b/app/routes/how-to-play.tsx
index b792e37..f4b3450 100644
--- a/app/routes/how-to-play.tsx
+++ b/app/routes/how-to-play.tsx
@@ -50,22 +50,40 @@ export default function HowToPlay() {
Points are awarded based on final standings. The better your pick
finishes, the more points you earn:
-
-
- ๐ฅ 1st Place
- 80 points
-
-
- ๐ฅ 2nd Place
- 50 points
-
-
- ๐ฅ 3rd-4th Place
- 30 points
-
-
- 5th-8th Place
- 20 points
+
+
+
+ ๐ฅ 1st Place
+ 100 pts
+
+
+ ๐ฅ 2nd Place
+ 70 pts
+
+
+ ๐ฅ 3rd Place
+ 50 pts
+
+
+ 4th Place
+ 40 pts
+
+
+ 5th Place
+ 25 pts
+
+
+ 6th Place
+ 25 pts
+
+
+ 7th Place
+ 15 pts
+
+
+ 8th Place
+ 15 pts
+
@@ -76,27 +94,37 @@ export default function HowToPlay() {
- โณ Special Scoring for Golf & Tennis
+ โณ Special Scoring for Golf, Tennis & Counter-Strike
- Golf and tennis work a bit differently since they have multiple
- major tournaments:
+ Golf, tennis, and Counter-Strike work a bit differently since they
+ each have multiple major tournaments throughout the year:
- Players earn qualifying points at each major tournament throughout
- the year
+ Players/teams earn qualifying points at each major tournament
+ throughout the year
After all majors are complete, we add up everyone's qualifying
points
- The top 8 players by total qualifying points then earn the regular
- scoring points (80, 50, 30, 20)
+ The top 8 by total qualifying points then earn the regular scoring
+ points (100, 70, 50, 40, 25, 25, 15, 15)
This rewards consistency across all the major tournaments!
{actionData.error}
diff --git a/plan.md b/plan.md
index 538e3f3..76c6b86 100644
--- a/plan.md
+++ b/plan.md
@@ -1,114 +1,66 @@
-# Plan: Push Notifications Toggle on Draft Page
+# Plan: Commissioner Without a Team
-## Overview
+## Current State
-Add a toggle to the draft page sidebar that enables browser push notifications for draft events. When enabled, the user receives desktop notifications when:
-1. **It's their turn to pick** (highest priority)
-2. **A pick is made by any team** (so they can track the draft from another tab/window)
+**At the database level**, commissioners and teams are already independent:
+- Commissioners are stored in the `commissioners` table (league-level, keyed by `leagueId` + `userId`)
+- Teams are stored in the `teams` table (season-level, keyed by `seasonId` + `ownerId`)
+- A commissioner does NOT need a team to access the league homepage or draft room โ this already works
-This uses the **Browser Notification API** (`Notification.requestPermission()` + `new Notification()`), which is the right fit because:
-- Users already have an active Socket.IO connection on the draft page
-- The primary use case is "I have the draft tab open but I'm looking at another tab/window"
-- No server-side push infrastructure, service workers, or VAPID keys needed
-- Simple, client-side-only feature โ no database changes required
+**The actual problem** is that there's no way to *become* a commissioner without also getting a team:
+1. **League creation** (`app/routes/leagues/new.tsx:198-201`): The creator is always assigned the first team (`ownerId: i === 0 ? userId : null`)
+2. **No "Add Commissioner" UI**: There's no way for a league creator to add additional commissioners at all
+3. **Invite link flow** (`app/routes/i.$inviteCode.tsx`): Joining always assigns a team โ there's no option to join as commissioner-only
-## Implementation Steps
+**Also**, the league creation flow has no opt-out for team ownership โ the creator always gets Team 1.
-### 1. Create `NotificationSettings` component
-**New file:** `app/components/NotificationSettings.tsx`
+## Changes
-A toggle switch component (mirroring `AutodraftSettings.tsx` pattern) that:
-- Shows a Switch toggle labeled "Notifications"
-- On first enable: calls `Notification.requestPermission()` to request browser permission
-- Stores the enabled/disabled preference in `localStorage` (keyed per season, e.g. `draftNotifications-{seasonId}`)
-- Shows a small help message if the browser permission is `denied`
-- Handles the case where the Notification API is unavailable (e.g. insecure context) by hiding the toggle entirely
-- Receives no server props โ this is entirely client-side state
+### 1. League creation: Add "Join as player" checkbox
+**File**: `app/routes/leagues/new.tsx`
-**Props:**
-```typescript
-interface NotificationSettingsProps {
- seasonId: string;
- enabled: boolean;
- onEnabledChange: (enabled: boolean) => void;
-}
-```
+- Add a checkbox: "I want to play in this league" (checked by default)
+- When unchecked, skip assigning the creator to the first team (all teams get `ownerId: null`)
+- When checked, behavior stays the same (creator gets first team)
-### 2. Create `useDraftNotifications` hook
-**New file:** `app/hooks/useDraftNotifications.ts`
+### 2. Add Commissioner Management UI to League Settings
+**File**: `app/routes/leagues/$leagueId.settings.tsx`
-Custom hook that:
-- Manages the notification enabled/disabled state (persisted to `localStorage`)
-- Checks browser support for Notification API on mount
-- Returns `{ notificationsEnabled, setNotificationsEnabled, isSupported }`
-- The actual notification firing will happen in the draft page component where socket events are already handled
+- Add a new "Commissioner Management" card section
+- Show current commissioners with a "Remove" button (cannot remove if they're the last commissioner)
+- Add an "Add Commissioner" form with a user search/select dropdown
+ - Only show users who are already members of the league (team owners) OR allow adding by searching all users
+ - Since only the site admin can currently assign users to teams, let's keep it simple: commissioners can add any registered user as a commissioner
+- Backend actions: `add-commissioner` and `remove-commissioner` intents
-### 3. Wire into the draft page
-**Modify:** `app/routes/leagues/$leagueId.draft.$seasonId.tsx`
+### 3. Model changes
+**File**: `app/models/commissioner.ts`
-- Import and use `useDraftNotifications` hook
-- In the existing `handlePickMade` socket handler (line ~359), add logic:
- - If notifications are enabled and `document.hidden` (tab is not focused):
- - Fire a `new Notification()` with the pick info (e.g. "Team X drafted Player Y")
- - If the pick means it's now the current user's turn, fire a more urgent notification ("It's your turn to pick!")
-- Pass notification state down to the `QueueSection` component
+- Already has `createCommissioner`, `deleteCommissioner`, `removeCommissionerByLeagueAndUser` โ these are sufficient
+- Add `countCommissionersByLeagueId` to prevent removing the last commissioner
-### 4. Add toggle to the sidebar
-**Modify:** `app/components/draft/QueueSection.tsx`
+### 4. Settings loader changes
+**File**: `app/routes/leagues/$leagueId.settings.tsx`
-- Add the `NotificationSettings` component below the `AutodraftSettings` component (inside the same sidebar section)
-- Accept new props for notification state
+- Load commissioners list and user data for display
+- Load all users list for the "add commissioner" dropdown (reuse existing `allUsers` pattern but make it available to commissioners, not just admins)
-## UI Placement
+### 5. League homepage: Show commissioner-only indicator
+**File**: `app/routes/leagues/$leagueId.tsx`
-The toggle will sit in the sidebar's queue section, right below the existing Autodraft toggle:
+- In the commissioners list, if a commissioner doesn't own a team in the current season, show a "(No team)" indicator next to their name
+- This helps distinguish playing commissioners from non-playing commissioners
-```
-โโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ My Queue โ
-โ โโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โ 1. Player A [ร] โ โ
-โ โ 2. Player B [ร] โ โ
-โ โโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โ
-โ โโโ Autodraft โโโโ [ยท] โ
-โ โ Next Pick โ
-โ โ While On โ
-โ โ
-โ โโโ Notifications โ [ยท] โ โ NEW
-โ โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
+## Files to modify
-## Notification Content
+1. `app/routes/leagues/new.tsx` โ Add "play in league" checkbox, conditionally assign first team
+2. `app/models/commissioner.ts` โ Add `countCommissionersByLeagueId` helper
+3. `app/routes/leagues/$leagueId.settings.tsx` โ Add commissioner management card (loader + action + UI)
+4. `app/routes/leagues/$leagueId.server.ts` โ Pass team ownership info for commissioners to the homepage
+5. `app/routes/leagues/$leagueId.tsx` โ Show "(No team)" indicator for commissioners without teams
-| Event | Condition | Title | Body |
-|-------|-----------|-------|------|
-| Pick made | Tab not focused, notifications on | "{League} Draft" | "{TeamName} picked {ParticipantName}" |
-| Your turn | Tab not focused, notifications on, it's now user's turn | "{League} Draft" | "It's your turn to pick!" |
+## Out of scope (not changing)
-Notifications will include `{ tag: "draft-{seasonId}" }` so they replace each other rather than stacking.
-
-## Edge Cases Handled
-
-- **Notification API not available** (HTTP, unsupported browser): Toggle hidden entirely via `isSupported` check
-- **Permission denied**: Show inline message "Notifications blocked by browser. Update in browser settings."
-- **Permission not yet requested**: Request on first toggle enable
-- **Tab is focused**: Don't fire notifications (user can already see the draft)
-- **SSR**: All Notification API checks guarded with `typeof window !== "undefined"`
-
-## Files Changed
-
-| File | Change |
-|------|--------|
-| `app/hooks/useDraftNotifications.ts` | **New** โ hook for notification state + localStorage persistence |
-| `app/components/NotificationSettings.tsx` | **New** โ toggle UI component |
-| `app/components/draft/QueueSection.tsx` | **Modified** โ add NotificationSettings below AutodraftSettings |
-| `app/routes/leagues/$leagueId.draft.$seasonId.tsx` | **Modified** โ use hook, fire notifications in socket handlers, pass props to QueueSection |
-
-## What This Does NOT Include
-
-- **Server-side web push** (VAPID keys, web-push library, service worker, push subscriptions table) โ not needed for the core use case. Can be added later if users want notifications when the tab is completely closed.
-- **Sound notifications** โ could be added as a follow-up.
-- **Granular notification preferences** (per-event toggles) โ a single on/off toggle is sufficient for v1. Can be expanded later.
-- **Database storage** โ preference stored in localStorage since it's device-specific and doesn't need to sync across devices.
+- The invite link flow โ this is for players joining, not commissioners. Commissioners should be added through settings.
+- The `createdBy` vs commissioners table inconsistency in draft control endpoints โ that's a separate bug
+- Adding a way for a commissioner to later claim/release a team mid-season โ possible future work but not needed here