Merge branch 'main' into claude/push-notifications-toggle-6F8kB
This commit is contained in:
commit
f9823b7391
11 changed files with 373 additions and 167 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -71,23 +71,38 @@ 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({
|
||||
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<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()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="flex justify-between items-center">
|
||||
<span className="font-semibold">🥇 1st Place</span>
|
||||
<span className="text-xl font-bold">80 points</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>
|
||||
</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>
|
||||
</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>
|
||||
<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">100 pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold">🥈 2nd Place</span>
|
||||
<span className="text-xl font-bold">70 pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<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">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>
|
||||
|
|
|
|||
|
|
@ -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("_", " ")}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,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<Set<string>>(
|
||||
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
|
||||
);
|
||||
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
||||
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<number>(season?.draftRounds || 20);
|
||||
const [draftDate, setDraftDate] = useState<Date | undefined>(
|
||||
|
|
@ -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
|
|||
</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,12 +1087,11 @@ 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}
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
value={user.clerkId}
|
||||
disabled={userOwnsTeam}
|
||||
>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
142
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue