brackt/app/routes/leagues/$leagueId.tsx

318 lines
13 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import type { Route } from "./+types/$leagueId";
2026-04-14 20:26:02 -07:00
import { Link2 } from "lucide-react";
import { Button } from "~/components/ui/button";
2026-04-14 20:26:02 -07:00
import { GradientIcon } from "~/components/ui/GradientIcon";
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
import { TeamsPanel } from "~/components/league/TeamsPanel";
import { DraftInfoCard } from "~/components/league/DraftInfoCard";
2026-04-14 09:39:13 -07:00
import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
2026-04-14 20:26:02 -07:00
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
}
export { loader } from "./$leagueId.server";
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
const {
league,
season,
teams,
commissioners,
currentUserId,
isUserCommissioner,
ownerMap,
commissionerMap,
availableTeamCount,
sportsCount,
isDraftOrderSet,
User/chris/bracket UI redesign (#95) * feat: redesign playoff bracket UI with compact layout and owner display - Replace per-match Card wrappers with compact left-vs-right rows (stacks on mobile) - Add TeamOwnerBadge component (colored avatar + team name + username), matching the standings "Drafted By" style - Show owner info below each participant name in bracket matches - Add TBD forward-reference labels ("Winner of Quarterfinals M1") computed via buildFeederMap - Add Final Rankings table below bracket showing all participants ranked by elimination round - Fix round ordering in loader: sort by match count descending (more matches = earlier round) - Add loser relation to playoff matches query for elimination tracking - Extract getAvatarColor to app/lib/color-hash.ts (shared across GroupStageDisplay, SeasonStandings, TeamOwnerBadge) - Extract groupMatchesByRound helper; share grouped map between feeder computation and render - Remove dead getTeamAvatar from SeasonStandings after TeamOwnerBadge adoption - Add tests for groupMatchesByRound and buildFeederMap (7 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add draft order card to league home page Show draft order on the league home page when order is set and season is in pre_draft or draft status. Includes team name, owner name, and a link to the draft room. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: enhance playoff bracket with eliminated teams, points, and ownership highlights - Show eliminated teams (including group-stage losers) in Final Rankings card - Display computed fantasy points per participant in rankings table - Card title switches between "Eliminated Teams" and "Final Rankings" based on bracket completion - Highlight owned participants with electric blue name, dot indicator, and card border in bracket matches - Highlight owned rows with electric border/background in rankings table - Suppress EventSchedule for playoff_bracket sports seasons - Fix title redundancy: bracket section title no longer repeats sport name - Two-column match grid on desktop (md:grid-cols-2) - Code review fixes: parallel DB fetches, targeted query for pre-eliminated, single-pass parseFloat, remove IIFE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 22:44:33 -07:00
draftSlots,
sportsSeasons,
2026-03-05 10:07:15 -08:00
standings,
origin,
2026-03-12 10:12:38 -07:00
upcomingCalendarEvents,
Add audit logging for commissioner actions (#293) Closes #144 * feat: add commissioner audit log for league transparency (issue #144) Adds a complete audit log system so league members can verify that settings, draft order, picks, and time banks have not been quietly changed without their awareness. Changes: - database/schema.ts: new `audit_action` enum + `commissioner_audit_log` table (seasonId, leagueId, actorClerkId, actorDisplayName, action, affectedTeamIds[], details jsonb, createdAt) - drizzle/0075: generated migration for the new table - app/models/audit-log.ts: createAuditLogEntry, getAuditLogForSeason (paginated), logCommissionerAction (resolves display name automatically) - app/lib/audit-log-display.ts: shared formatAuditDetail() helper used by both the league home widget and the full audit log page - app/routes/leagues/$leagueId.audit-log.tsx: new read-only route at /leagues/:id/audit-log, accessible to all league members, with action-type filter and pagination - app/routes.ts: registers the new route - League home page ($leagueId.server.ts / $leagueId.tsx): "Recent Activity" summary card showing the last 5 entries with "View all" link - Settings page ($leagueId.settings.tsx): "View Full Audit Log" link card; audit log calls added for league/draft settings changes, draft order set/randomized, and draft reset - API routes: audit log calls added to draft.start, draft.pause, draft.resume, draft.rollback, draft.adjust-time-bank, draft.force-autopick, draft.force-manual-pick, draft.replace-pick - Tests: 11 new unit tests for the audit-log model; mocks added to 3 existing route test files to account for the new logCommissionerAction call https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * fix: validate action filter URL param against known enum values The action filter on the audit log route was cast directly from the URL search param to AuditAction without validation. An invalid value would be passed into the Drizzle inArray() call, potentially throwing a PostgreSQL enum type error. Now validates against the actual enum values before using the filter. https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * Fix lint errors: use !== instead of != and toSorted instead of sort https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
recentActivity,
} = loaderData;
2026-03-05 10:07:15 -08:00
const myTeam = teams.find((t) => t.ownerId === currentUserId);
2026-04-14 20:26:02 -07:00
// 1-based pick position; undefined if myTeam isn't in the slots (order not yet set, or no team)
const userDraftPosition = myTeam
? draftSlots.findIndex((s) => s.team.id === myTeam.id) + 1 || undefined
: undefined;
const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false);
// Guard against double-firing toasts (e.g. React Strict Mode double-invoke)
const processedParamsRef = useRef<string | null>(null);
useEffect(() => {
const paramString = searchParams.toString();
if (!paramString || paramString === processedParamsRef.current) return;
if (searchParams.get("updated") === "true") {
processedParamsRef.current = paramString;
toast.success("Team updated successfully");
setSearchParams({});
} else if (searchParams.get("joined") === "true") {
processedParamsRef.current = paramString;
toast.success("Welcome to the league! You've been assigned a team.");
setSearchParams({});
} else if (searchParams.get("left") === "true") {
processedParamsRef.current = paramString;
toast.success("You have left the league. Your team is now available.");
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
const inviteUrl = `${origin}/i/${season.inviteCode}`;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success("Invite link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy invite link");
}
};
// Pre-compute standings map to avoid O(n²) lookups in render
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
const isTiedRank = buildTiedRankChecker(standings.map((s) => s.currentRank));
// Pre-compute sorted standings list
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
const sortedTeams = [...teams].toSorted((a, b) => {
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity;
return rankA - rankB;
});
const standingsEntries: StandingsPreviewEntry[] = sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
return {
teamId: team.id,
teamName: team.name,
ownerName: team.ownerId ? ownerMap[team.ownerId] : null,
displayRank: getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false),
currentRank: standing?.currentRank,
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
rankChange: standing?.rankChange,
pointChange: standing?.sevenDayPointChange,
};
});
// Pre-compute sorted sports seasons: active → upcoming → completed,
// season_standings last within active, then alphabetical by sport name
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
const statusOrder = { active: 0, upcoming: 1, completed: 2 } as const;
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
if (a.status === "active") {
const aSeasonLong = a.scoringPattern === "season_standings" ? 1 : 0;
const bSeasonLong = b.scoringPattern === "season_standings" ? 1 : 0;
if (aSeasonLong !== bSeasonLong) return aSeasonLong - bSeasonLong;
}
return a.sport.name.localeCompare(b.sport.name);
});
const isDraftOrPreDraft = season?.status === "pre_draft" || season?.status === "draft";
const isActiveOrCompleted = season?.status === "active" || season?.status === "completed";
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
2026-04-14 20:26:02 -07:00
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div>
<h1 className="text-4xl font-bold mb-2">{league.name}</h1>
{season && (
<p className="text-muted-foreground">
{season.year} Season {" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</p>
)}
</div>
<div className="flex gap-2 shrink-0">
2026-03-05 10:07:15 -08:00
{myTeam && (
2026-04-14 20:26:02 -07:00
<Button variant="outline" size="sm" asChild>
2026-03-05 10:07:15 -08:00
<Link to={`/teams/${myTeam.id}/settings`}>
Team Settings
</Link>
</Button>
)}
{isUserCommissioner && (
2026-04-14 20:26:02 -07:00
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/settings`}>
League Settings
</Link>
</Button>
)}
</div>
</div>
</div>
{season?.status === "draft" && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-yellow-700 dark:text-yellow-400">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-500 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-yellow-500" />
</span>
<span className="font-medium">Draft is live!</span>
<span className="hidden text-sm sm:inline">
Your league draft is currently in progress.
</span>
</div>
<Button asChild size="sm" variant="outline" className="shrink-0 border-yellow-500/60 text-yellow-700 hover:bg-yellow-500/20 dark:text-yellow-400">
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
Enter Draft Room
</Link>
</Button>
</div>
)}
<div className="grid gap-6 md:grid-cols-3">
{/* Left Column - 2/3 width on desktop */}
<div className="md:col-span-2 space-y-6">
2026-04-14 20:26:02 -07:00
{/* Draft Info card - pre_draft/draft only */}
{season && isDraftOrPreDraft && (
<DraftInfoCard
draftRounds={season.draftRounds}
draftTimerMode={season.draftTimerMode}
draftInitialTime={season.draftInitialTime}
draftIncrementTime={season.draftIncrementTime}
sportsCount={sportsCount}
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={isDraftOrPreDraft}
draftDateTime={season.draftDateTime}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
userDraftPosition={userDraftPosition}
/>
)}
2026-03-05 10:07:15 -08:00
{/* Standings Panel - active/completed seasons */}
{season && isActiveOrCompleted && (
<StandingsPreview
entries={standingsEntries}
description={season.status === "completed" ? "Final standings" : undefined}
2026-04-14 20:26:02 -07:00
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
/>
2026-03-05 10:07:15 -08:00
)}
{/* Sports Seasons Section */}
{season && sortedSportsSeasons.length > 0 && (
2026-04-14 09:39:13 -07:00
<SportsSeasonsSummary
leagueId={league.id}
sportsSeasons={sortedSportsSeasons.map((ss) => ({
id: ss.id,
sportName: ss.sport.name,
seasonName: ss.name,
status: ss.status,
scoringPattern: ss.scoringPattern,
upcomingParticipantEvents: ss.upcomingParticipantEvents,
draftedParticipants: ss.draftedParticipantsWithPoints,
}))}
/>
)}
2026-04-14 20:26:02 -07:00
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-14">
{/* Teams - shown above invite link when league is full */}
{season && isDraftOrPreDraft && availableTeamCount === 0 && (
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
)}
{/* Invite Link - bare, pre_draft commissioner only */}
{isUserCommissioner && season?.status === "pre_draft" && availableTeamCount > 0 && (
<div>
<div className="flex items-center gap-2 mb-4">
<GradientIcon icon={Link2} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Invite Link</span>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""} remaining
</p>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${origin}/i/${season.inviteCode}`}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
onClick={(e) => e.currentTarget.select()}
/>
<Button
onClick={handleCopyInviteLink}
variant={copied ? "default" : "outline"}
size="sm"
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
2026-04-14 20:26:02 -07:00
</div>
</div>
User/chris/bracket UI redesign (#95) * feat: redesign playoff bracket UI with compact layout and owner display - Replace per-match Card wrappers with compact left-vs-right rows (stacks on mobile) - Add TeamOwnerBadge component (colored avatar + team name + username), matching the standings "Drafted By" style - Show owner info below each participant name in bracket matches - Add TBD forward-reference labels ("Winner of Quarterfinals M1") computed via buildFeederMap - Add Final Rankings table below bracket showing all participants ranked by elimination round - Fix round ordering in loader: sort by match count descending (more matches = earlier round) - Add loser relation to playoff matches query for elimination tracking - Extract getAvatarColor to app/lib/color-hash.ts (shared across GroupStageDisplay, SeasonStandings, TeamOwnerBadge) - Extract groupMatchesByRound helper; share grouped map between feeder computation and render - Remove dead getTeamAvatar from SeasonStandings after TeamOwnerBadge adoption - Add tests for groupMatchesByRound and buildFeederMap (7 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add draft order card to league home page Show draft order on the league home page when order is set and season is in pre_draft or draft status. Includes team name, owner name, and a link to the draft room. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: enhance playoff bracket with eliminated teams, points, and ownership highlights - Show eliminated teams (including group-stage losers) in Final Rankings card - Display computed fantasy points per participant in rankings table - Card title switches between "Eliminated Teams" and "Final Rankings" based on bracket completion - Highlight owned participants with electric blue name, dot indicator, and card border in bracket matches - Highlight owned rows with electric border/background in rankings table - Suppress EventSchedule for playoff_bracket sports seasons - Fix title redundancy: bracket section title no longer repeats sport name - Two-column match grid on desktop (md:grid-cols-2) - Code review fixes: parallel DB fetches, targeted query for pre-eliminated, single-pass parseFloat, remove IIFE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 22:44:33 -07:00
)}
2026-04-14 20:26:02 -07:00
{/* Teams - shown below invite link when league is not full */}
{season && isDraftOrPreDraft && availableTeamCount > 0 && (
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
)}
2026-03-12 10:12:38 -07:00
{upcomingCalendarEvents.length > 0 && (
2026-04-14 09:39:13 -07:00
<UpcomingEventsCard
events={upcomingCalendarEvents}
2026-04-14 09:39:13 -07:00
title="Upcoming Events"
limit={6}
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
2026-04-14 20:26:02 -07:00
showLeagueAvatar={false}
/>
2026-03-12 10:12:38 -07:00
)}
Add audit logging for commissioner actions (#293) Closes #144 * feat: add commissioner audit log for league transparency (issue #144) Adds a complete audit log system so league members can verify that settings, draft order, picks, and time banks have not been quietly changed without their awareness. Changes: - database/schema.ts: new `audit_action` enum + `commissioner_audit_log` table (seasonId, leagueId, actorClerkId, actorDisplayName, action, affectedTeamIds[], details jsonb, createdAt) - drizzle/0075: generated migration for the new table - app/models/audit-log.ts: createAuditLogEntry, getAuditLogForSeason (paginated), logCommissionerAction (resolves display name automatically) - app/lib/audit-log-display.ts: shared formatAuditDetail() helper used by both the league home widget and the full audit log page - app/routes/leagues/$leagueId.audit-log.tsx: new read-only route at /leagues/:id/audit-log, accessible to all league members, with action-type filter and pagination - app/routes.ts: registers the new route - League home page ($leagueId.server.ts / $leagueId.tsx): "Recent Activity" summary card showing the last 5 entries with "View all" link - Settings page ($leagueId.settings.tsx): "View Full Audit Log" link card; audit log calls added for league/draft settings changes, draft order set/randomized, and draft reset - API routes: audit log calls added to draft.start, draft.pause, draft.resume, draft.rollback, draft.adjust-time-bank, draft.force-autopick, draft.force-manual-pick, draft.replace-pick - Tests: 11 new unit tests for the audit-log model; mocks added to 3 existing route test files to account for the new logCommissionerAction call https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * fix: validate action filter URL param against known enum values The action filter on the audit log route was cast directly from the URL search param to AuditAction without validation. An invalid value would be passed into the Drizzle inArray() call, potentially throwing a PostgreSQL enum type error. Now validates against the actual enum values before using the filter. https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * Fix lint errors: use !== instead of != and toSorted instead of sort https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
2026-04-14 20:26:02 -07:00
{/* Draft historical view - active/completed only */}
{season && isActiveOrCompleted && (
<DraftInfoCard
draftRounds={season.draftRounds}
draftTimerMode={season.draftTimerMode}
draftInitialTime={season.draftInitialTime}
draftIncrementTime={season.draftIncrementTime}
sportsCount={sportsCount}
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={false}
draftDateTime={season.draftDateTime}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
/>
Add audit logging for commissioner actions (#293) Closes #144 * feat: add commissioner audit log for league transparency (issue #144) Adds a complete audit log system so league members can verify that settings, draft order, picks, and time banks have not been quietly changed without their awareness. Changes: - database/schema.ts: new `audit_action` enum + `commissioner_audit_log` table (seasonId, leagueId, actorClerkId, actorDisplayName, action, affectedTeamIds[], details jsonb, createdAt) - drizzle/0075: generated migration for the new table - app/models/audit-log.ts: createAuditLogEntry, getAuditLogForSeason (paginated), logCommissionerAction (resolves display name automatically) - app/lib/audit-log-display.ts: shared formatAuditDetail() helper used by both the league home widget and the full audit log page - app/routes/leagues/$leagueId.audit-log.tsx: new read-only route at /leagues/:id/audit-log, accessible to all league members, with action-type filter and pagination - app/routes.ts: registers the new route - League home page ($leagueId.server.ts / $leagueId.tsx): "Recent Activity" summary card showing the last 5 entries with "View all" link - Settings page ($leagueId.settings.tsx): "View Full Audit Log" link card; audit log calls added for league/draft settings changes, draft order set/randomized, and draft reset - API routes: audit log calls added to draft.start, draft.pause, draft.resume, draft.rollback, draft.adjust-time-bank, draft.force-autopick, draft.force-manual-pick, draft.replace-pick - Tests: 11 new unit tests for the audit-log model; mocks added to 3 existing route test files to account for the new logCommissionerAction call https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * fix: validate action filter URL param against known enum values The action filter on the audit log route was cast directly from the URL search param to AuditAction without validation. An invalid value would be passed into the Drizzle inArray() call, potentially throwing a PostgreSQL enum type error. Now validates against the actual enum values before using the filter. https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * Fix lint errors: use !== instead of != and toSorted instead of sort https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
)}
2026-04-14 20:26:02 -07:00
<CommissionersPanel
commissioners={commissioners}
commissionerMap={commissionerMap}
currentUserId={currentUserId}
teams={teams}
activityEntries={recentActivity.entries}
auditLogUrl={`/leagues/${league.id}/audit-log`}
/>
</div>
</div>
</div>
);
}