Merge branch 'main' into claude/push-notifications-toggle-6F8kB

This commit is contained in:
Chris Parsons 2026-02-20 14:40:35 -08:00 committed by GitHub
commit f9823b7391
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 373 additions and 167 deletions

View file

@ -50,6 +50,16 @@ export async function isCommissioner(
return !!commissioner;
}
export async function countCommissionersByLeagueId(
leagueId: string
): Promise<number> {
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<void> {
const db = database();
await db.delete(schema.commissioners).where(eq(schema.commissioners.id, id));

View file

@ -71,9 +71,7 @@ export async function findLeaguesWithActiveSeasonsByUserId(
): Promise<League[]> {
const db = database();
// Query to find all leagues where user has a team in the current season
const leagues = await db
.selectDistinct({
const leagueFields = {
id: schema.leagues.id,
name: schema.leagues.name,
createdBy: schema.leagues.createdBy,
@ -81,13 +79,30 @@ export async function findLeaguesWithActiveSeasonsByUserId(
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<string, League>();
for (const league of [...leaguesWithTeam, ...leaguesAsCommissioner]) {
leagueMap.set(league.id, league);
}
return [...leagueMap.values()].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
);
}
/**

View file

@ -92,9 +92,9 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{leagues.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Active Leagues</CardTitle>
<CardTitle>No Leagues</CardTitle>
<CardDescription>
You don't have any teams in leagues with active seasons yet
You aren't a member of any leagues yet
</CardDescription>
</CardHeader>
<CardContent>

View file

@ -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:
</p>
<div className="bg-muted p-6 rounded-lg space-y-2">
<div className="bg-muted p-6 rounded-lg">
<div className="grid grid-cols-2 gap-x-8 gap-y-3">
<div className="flex justify-between items-center">
<span className="font-semibold">🥇 1st Place</span>
<span className="text-xl font-bold">80 points</span>
<span className="text-xl font-bold">100 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">🥈 2nd Place</span>
<span className="text-xl font-bold">50 points</span>
<span className="text-xl font-bold">70 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">🥉 3rd-4th Place</span>
<span className="text-xl font-bold">30 points</span>
<span className="font-semibold">🥉 3rd Place</span>
<span className="text-xl font-bold">50 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">5th-8th Place</span>
<span className="text-xl font-bold">20 points</span>
<span className="font-semibold">4th Place</span>
<span className="text-xl font-bold">40 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">5th Place</span>
<span className="text-xl font-bold">25 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">6th Place</span>
<span className="text-xl font-bold">25 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">7th Place</span>
<span className="text-xl font-bold">15 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">8th Place</span>
<span className="text-xl font-bold">15 pts</span>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4 italic">
@ -76,27 +94,37 @@ export default function HowToPlay() {
<section>
<h2 className="text-2xl font-semibold mb-4">
Special Scoring for Golf & Tennis
Special Scoring for Golf, Tennis & Counter-Strike
</h2>
<p className="text-lg text-muted-foreground mb-4">
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:
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground ml-4">
<li>
Players earn qualifying points at each major tournament throughout
the year
Players/teams earn qualifying points at each major tournament
throughout the year
</li>
<li>
After all majors are complete, we add up everyone's qualifying
points
</li>
<li>
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)
</li>
<li>This rewards consistency across all the major tournaments!</li>
</ul>
<div className="bg-muted p-4 rounded-lg mt-4">
<p className="font-semibold mb-2">Qualifying points per major:</p>
<div className="grid grid-cols-2 gap-1 text-sm text-muted-foreground">
<span>1st Place</span><span className="font-medium">8 QP</span>
<span>2nd Place</span><span className="font-medium">5 QP</span>
<span>3rd Place</span><span className="font-medium">3 QP</span>
<span>4th Place</span><span className="font-medium">2 QP</span>
<span>5th Place</span><span className="font-medium">1 QP</span>
</div>
</div>
</section>
<section>

View file

@ -150,7 +150,7 @@ export default function DraftBoard() {
{season.league.name} - {season.year} Draft Board
</h1>
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
<span>Round: {Math.ceil(currentPick / totalTeams)}</span>
<span>Round: {totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1}</span>
<span>Pick: {currentPick}</span>
<span className="capitalize">
{season.status.replace("_", " ")}

View file

@ -701,7 +701,7 @@ export default function DraftRoom() {
};
// Calculate current round
const currentRound = Math.ceil(currentPick / draftSlots.length);
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
// Determine whose turn it is
const currentDraftSlot = getTeamForPick(currentPick, draftSlots);

View file

@ -4,7 +4,13 @@ import { getAuth } from "@clerk/react-router/server";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { isCommissioner } from "~/models/commissioner";
import {
isCommissioner,
findCommissionersByLeagueId,
createCommissioner,
countCommissionersByLeagueId,
removeCommissionerByLeagueAndUser,
} from "~/models/commissioner";
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
import { findTeamsBySeasonId, createManyTeams, deleteTeam, removeTeamOwner, assignTeamOwner } from "~/models/team";
import { findAllUsers, findUserByClerkId, isUserAdminByClerkId } from "~/models/user";
@ -92,9 +98,21 @@ export async function loader(args: Route.LoaderArgs) {
// Check if user is admin
const isAdmin = await isUserAdminByClerkId(userId);
// Get all users if admin (for team assignment dropdown)
// Get all users (only for admins - used in team assignment dropdown)
const allUsers = isAdmin ? await findAllUsers() : [];
// Get commissioners for this league with user info
const commissioners = await findCommissionersByLeagueId(leagueId);
const commissionerUserData = await Promise.all(
commissioners.map(async (c) => {
const user = await findUserByClerkId(c.userId);
return {
...c,
userName: user?.username || user?.displayName || "Unknown User",
};
})
);
// Get owner details for teams
const ownerIds = teams
.map((t) => t.ownerId)
@ -108,11 +126,15 @@ export async function loader(args: Route.LoaderArgs) {
: null;
})
);
const ownerMap = new Map(
owners
.filter((o): o is NonNullable<typeof o> => o !== null)
.map((o) => [o.clerkId, o.name])
);
const validOwners = owners.filter((o): o is NonNullable<typeof o> => o !== null);
const ownerMap = new Map(validOwners.map((o) => [o.clerkId, o.name]));
// League members (team owners) - available to all commissioners for adding co-commissioners
const leagueMembers = validOwners.map((o) => ({
id: o.id,
clerkId: o.clerkId,
name: o.name,
}));
return {
league,
@ -124,7 +146,10 @@ export async function loader(args: Route.LoaderArgs) {
draftSlots,
isAdmin,
allUsers,
leagueMembers,
ownerMap: Object.fromEntries(ownerMap),
commissioners: commissionerUserData,
currentUserId: userId,
};
}
@ -471,11 +496,60 @@ export async function action(args: Route.ActionArgs) {
}
}
if (intent === "add-commissioner") {
const userClerkId = formData.get("userClerkId") as string;
if (!userClerkId) {
return { error: "User is required" };
}
// Check if user is already a commissioner
const alreadyCommissioner = await isCommissioner(leagueId, userClerkId);
if (alreadyCommissioner) {
return { error: "This user is already a commissioner" };
}
try {
await createCommissioner({ leagueId, userId: userClerkId });
return { success: true, message: "Commissioner added successfully" };
} catch (error) {
console.error("Error adding commissioner:", error);
return { error: "Failed to add commissioner. Please try again." };
}
}
if (intent === "remove-commissioner") {
const commissionerUserId = formData.get("commissionerUserId") as string;
if (!commissionerUserId) {
return { error: "User is required" };
}
// Prevent self-removal
if (commissionerUserId === userId) {
return { error: "You cannot remove yourself as a commissioner" };
}
// Prevent removing the last commissioner
const count = await countCommissionersByLeagueId(leagueId);
if (count <= 1) {
return { error: "Cannot remove the last commissioner" };
}
try {
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
return { success: true, message: "Commissioner removed successfully" };
} catch (error) {
console.error("Error removing commissioner:", error);
return { error: "Failed to remove commissioner. Please try again." };
}
}
return { error: "Invalid action" };
}
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, ownerMap } = loaderData;
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedSports, setSelectedSports] = useState<Set<string>>(
@ -483,8 +557,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
);
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0
? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id)
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id)
);
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
const [draftDate, setDraftDate] = useState<Date | undefined>(
@ -499,8 +573,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
// Update draft order when loader data changes (after randomization)
useEffect(() => {
const newOrder = draftSlots.length > 0
? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id);
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id);
setDraftOrderTeams(newOrder);
}, [draftSlots, teams]);
@ -539,7 +613,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
};
const getTeamById = (teamId: string) => {
return teams.find((t: any) => t.id === teamId);
return teams.find((t) => t.id === teamId);
};
return (
@ -972,7 +1046,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</CardHeader>
<CardContent>
<div className="space-y-3">
{teams.map((team: any) => {
{teams.map((team) => {
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div
@ -1013,9 +1087,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
{allUsers.map((user: any) => {
// Check if user already owns a team in this league
const userOwnsTeam = teams.some((t: any) => t.ownerId === user.clerkId);
{allUsers.map((user) => {
const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId);
return (
<SelectItem
key={user.id}
@ -1047,6 +1120,118 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</CardContent>
</Card>
{/* Commissioner Management */}
<Card>
<CardHeader>
<CardTitle>Commissioner Management</CardTitle>
<CardDescription>
Commissioners can manage league settings and oversee the draft. They do not need to own a team.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{actionData?.error && !actionData?.success && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && actionData?.message && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
{actionData.message}
</div>
)}
<div className="space-y-3">
{commissioners.map((commissioner) => (
<div
key={commissioner.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1">
<p className="font-medium">
{commissioner.userName}
{commissioner.userId === currentUserId && (
<span className="text-xs text-muted-foreground ml-2">(you)</span>
)}
</p>
{!teams.some((t) => t.ownerId === commissioner.userId) && (
<p className="text-xs text-muted-foreground">No team in current season</p>
)}
</div>
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-commissioner" />
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Remove
</Button>
</Form>
)}
</div>
))}
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium mb-3">Add Commissioner</p>
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="add-commissioner" />
<Select name="userClerkId" required>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{isAdmin ? (
allUsers.map((user) => {
const alreadyCommissioner = commissioners.some(
(c) => c.userId === user.clerkId
);
return (
<SelectItem
key={user.id}
value={user.clerkId}
disabled={alreadyCommissioner}
>
{user.username || user.displayName || user.email}
{alreadyCommissioner && " (already commissioner)"}
</SelectItem>
);
})
) : (
leagueMembers.map((member) => {
const alreadyCommissioner = commissioners.some(
(c) => c.userId === member.clerkId
);
return (
<SelectItem
key={member.id}
value={member.clerkId}
disabled={alreadyCommissioner}
>
{member.name || "Unknown"}
{alreadyCommissioner && " (already commissioner)"}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Add
</Button>
</Form>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>

View file

@ -8,10 +8,8 @@ import {
findUserByClerkId,
} from "~/models";
import { getDraftPicks } from "~/models/draft-pick";
import { findSportsSeasonById } from "~/models/sports-season";
import { getSeasonResults } from "~/models/participant-season-result";
import { getQPStandings } from "~/models/qualifying-points";
import { findSeasonById } from "~/models/season";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
@ -64,19 +62,17 @@ export async function loader(args: Route.LoaderArgs) {
// Get the fantasy season for this league (to get teams and draft picks)
// We need to find which season in this league includes this sports season
const seasonSport = sportsSeason.seasonSports?.[0];
// Filter by leagueId since a sports season can be linked to multiple leagues
const seasonSport = sportsSeason.seasonSports?.find(
(ss) => ss.season.leagueId === leagueId
);
if (!seasonSport) {
throw new Response("This sports season is not linked to this league", {
status: 404,
});
}
const season = await findSeasonById(seasonSport.seasonId);
if (!season || season.leagueId !== leagueId) {
throw new Response("This sports season is not linked to this league", {
status: 404,
});
}
const season = seasonSport.season;
// Fetch teams and draft picks to determine ownership
const teams = await findTeamsBySeasonId(season.id);

View file

@ -373,6 +373,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
<div className="space-y-2">
{commissioners.map((commissioner) => {
const commissionerName = commissionerMap[commissioner.userId];
const hasTeam = teams.some(
(t) => t.ownerId === commissioner.userId
);
return (
<div
key={commissioner.id}
@ -386,6 +389,11 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</span>
)}
</p>
{!hasTeam && (
<p className="text-xs text-muted-foreground">
No team
</p>
)}
</div>
);
})}

View file

@ -194,11 +194,12 @@ export async function action(args: Route.ActionArgs) {
}
// Create teams with fun random names
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum);
const teams = teamNames.map((name, i) => ({
seasonId: season.id,
name,
ownerId: i === 0 ? userId : null, // Assign first team to creator
ownerId: joinAsPlayer && i === 0 ? userId : null,
}));
await createManyTeams(teams);
@ -485,6 +486,17 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="joinAsPlayer"
name="joinAsPlayer"
defaultChecked={true}
/>
<Label htmlFor="joinAsPlayer" className="font-normal cursor-pointer">
I want to play in this league (claim a team)
</Label>
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}

142
plan.md
View file

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