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

Qualifying points per major:

+
+ 1st Place8 QP + 2nd Place5 QP + 3rd Place3 QP + 4th Place2 QP + 5th Place1 QP +
+
diff --git a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx index a254e44..490f8f3 100644 --- a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx @@ -150,7 +150,7 @@ export default function DraftBoard() { {season.league.name} - {season.year} Draft Board
- Round: {Math.ceil(currentPick / totalTeams)} + Round: {totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1} Pick: {currentPick} {season.status.replace("_", " ")} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 11ee2df..08b7a9d 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -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); diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index 6227078..d7ebfbb 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -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 => o !== null) - .map((o) => [o.clerkId, o.name]) - ); + const validOwners = owners.filter((o): o is NonNullable => 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,20 +496,69 @@ 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>( new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || []) ); const [draftOrderTeams, setDraftOrderTeams] = useState( - draftSlots.length > 0 - ? draftSlots.map((slot: any) => slot.teamId) - : teams.map((team: any) => team.id) + draftSlots.length > 0 + ? draftSlots.map((slot) => slot.teamId) + : teams.map((team) => team.id) ); const [draftRounds, setDraftRounds] = useState(season?.draftRounds || 20); const [draftDate, setDraftDate] = useState( @@ -498,9 +572,9 @@ 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); + const newOrder = draftSlots.length > 0 + ? 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
- {teams.map((team: any) => { + {teams.map((team) => { const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; return (
- {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 ( - @@ -1047,6 +1120,118 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone + {/* Commissioner Management */} + + + Commissioner Management + + Commissioners can manage league settings and oversee the draft. They do not need to own a team. + + + + {actionData?.error && !actionData?.success && ( +
+ {actionData.error} +
+ )} + {actionData?.success && actionData?.message && ( +
+ {actionData.message} +
+ )} + +
+ {commissioners.map((commissioner) => ( +
+
+

+ {commissioner.userName} + {commissioner.userId === currentUserId && ( + (you) + )} +

+ {!teams.some((t) => t.ownerId === commissioner.userId) && ( +

No team in current season

+ )} +
+ {commissioners.length > 1 && commissioner.userId !== currentUserId && ( +
+ + + +
+ )} +
+ ))} +
+ +
+

Add Commissioner

+
+ + + +
+
+
+
+ {/* Danger Zone */} diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 31eaaaf..27bf6ee 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -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); diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 121eb2b..17fe447 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -373,6 +373,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{commissioners.map((commissioner) => { const commissionerName = commissionerMap[commissioner.userId]; + const hasTeam = teams.some( + (t) => t.ownerId === commissioner.userId + ); return (
)}

+ {!hasTeam && ( +

+ No team +

+ )}
); })} diff --git a/app/routes/leagues/new.tsx b/app/routes/leagues/new.tsx index e3bb3e5..7ee7110 100644 --- a/app/routes/leagues/new.tsx +++ b/app/routes/leagues/new.tsx @@ -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
+
+ + +
+ {actionData?.error && (
{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