2026-03-07 15:57:51 -08:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
2025-10-11 01:03:31 -07:00
|
|
|
import { Link, useSearchParams } from "react-router";
|
|
|
|
|
import { toast } from "sonner";
|
2025-10-15 22:02:21 -07:00
|
|
|
import { format } from "date-fns";
|
2025-10-11 00:07:39 -07:00
|
|
|
import type { Route } from "./+types/$leagueId";
|
2026-03-10 12:10:52 -07:00
|
|
|
|
2025-10-11 01:03:31 -07:00
|
|
|
import { Button } from "~/components/ui/button";
|
2025-10-11 00:07:39 -07:00
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "~/components/ui/card";
|
2025-11-14 20:39:51 -08:00
|
|
|
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
|
2026-03-12 10:12:38 -07:00
|
|
|
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
|
Display team owner names in standings views (#184)
* Show team name + username in standings, extract TeamNameDisplay component
- Add TeamNameDisplay component that renders team name (as link) with owner username below, matching the league homepage style
- Update StandingsTable to use TeamNameDisplay with owner username shown below team name
- Update league homepage standings section to use TeamNameDisplay
- Add ownerName/teamOwnerId fields to TeamStanding type
- Extend getSeasonStandings to include teamOwnerId from team relation
- Fetch and attach owner display names in the full standings page loader
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
* Address code review: type hygiene, explicit types, parallel fetching, style fix
- Remove teamOwnerId from TeamStanding type (was an internal server concern, not display data)
- Remove teamOwnerId from getSeasonStandings return value
- Add TeamStandingWithChange interface extending TeamStanding with sevenDayRankChange/sevenDayOldRank fields
- Annotate getSevenDayStandingsChange with explicit Promise<TeamStandingWithChange[]> return type
- Re-export TeamStandingWithChange from models/standings.ts
- Standings loader: query teams table directly for ownerIds instead of relying on teamOwnerId in standings data
- Standings loader: parallelize all independent queries (standings, teams, progressionData, seasonComplete, completionPercentage) with Promise.all
- Restore font-medium on StandingsTable team TableCell
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 20:19:28 -07:00
|
|
|
import { TeamNameDisplay } from "~/components/ui/team-name-display";
|
2026-03-27 20:45:15 -07:00
|
|
|
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
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
|
|
|
import { formatAuditDetail } from "~/lib/audit-log-display";
|
2025-10-11 00:07:39 -07:00
|
|
|
|
2026-03-10 12:10:52 -07:00
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
|
|
|
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-14 21:18:34 -08:00
|
|
|
export { loader } from "./$leagueId.server";
|
2025-10-11 00:07:39 -07:00
|
|
|
|
|
|
|
|
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
2025-10-14 21:24:58 -07:00
|
|
|
const {
|
|
|
|
|
league,
|
|
|
|
|
season,
|
|
|
|
|
teams,
|
|
|
|
|
commissioners,
|
|
|
|
|
currentUserId,
|
2025-10-14 21:20:58 -07:00
|
|
|
isUserCommissioner,
|
|
|
|
|
ownerMap,
|
|
|
|
|
commissionerMap,
|
|
|
|
|
availableTeamCount,
|
2025-10-15 21:50:02 -07:00
|
|
|
sportsCount,
|
2025-10-21 21:36:42 -07:00
|
|
|
teamsWithOwners,
|
|
|
|
|
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,
|
2025-11-14 20:39:51 -08:00
|
|
|
sportsSeasons,
|
2026-03-05 10:07:15 -08:00
|
|
|
standings,
|
2026-03-07 15:57:51 -08:00
|
|
|
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,
|
2025-10-14 21:20:58 -07:00
|
|
|
} = loaderData;
|
2026-03-07 15:57:51 -08:00
|
|
|
|
2026-03-05 10:07:15 -08:00
|
|
|
const myTeam = teams.find((t) => t.ownerId === currentUserId);
|
2025-10-11 01:03:31 -07:00
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
2025-10-14 12:20:36 -07:00
|
|
|
const [copied, setCopied] = useState(false);
|
2025-10-11 01:03:31 -07:00
|
|
|
|
2026-03-07 15:57:51 -08:00
|
|
|
// Guard against double-firing toasts (e.g. React Strict Mode double-invoke)
|
|
|
|
|
const processedParamsRef = useRef<string | null>(null);
|
2025-10-11 01:03:31 -07:00
|
|
|
useEffect(() => {
|
2026-03-07 15:57:51 -08:00
|
|
|
const paramString = searchParams.toString();
|
|
|
|
|
if (!paramString || paramString === processedParamsRef.current) return;
|
|
|
|
|
|
2025-10-11 01:03:31 -07:00
|
|
|
if (searchParams.get("updated") === "true") {
|
2026-03-07 15:57:51 -08:00
|
|
|
processedParamsRef.current = paramString;
|
2025-10-14 22:04:37 -07:00
|
|
|
toast.success("Team updated successfully");
|
2025-10-14 12:20:36 -07:00
|
|
|
setSearchParams({});
|
2026-03-07 15:57:51 -08:00
|
|
|
} else if (searchParams.get("joined") === "true") {
|
|
|
|
|
processedParamsRef.current = paramString;
|
2025-10-14 12:20:36 -07:00
|
|
|
toast.success("Welcome to the league! You've been assigned a team.");
|
2025-10-11 01:03:31 -07:00
|
|
|
setSearchParams({});
|
2026-03-07 15:57:51 -08:00
|
|
|
} else if (searchParams.get("left") === "true") {
|
|
|
|
|
processedParamsRef.current = paramString;
|
2025-10-14 22:04:37 -07:00
|
|
|
toast.success("You have left the league. Your team is now available.");
|
|
|
|
|
setSearchParams({});
|
|
|
|
|
}
|
2025-10-11 01:03:31 -07:00
|
|
|
}, [searchParams, setSearchParams]);
|
2025-10-11 00:07:39 -07:00
|
|
|
|
2025-10-14 12:20:36 -07:00
|
|
|
const handleCopyInviteLink = async () => {
|
|
|
|
|
if (!season?.inviteCode) return;
|
2025-10-14 21:24:58 -07:00
|
|
|
|
2026-03-07 15:57:51 -08:00
|
|
|
const inviteUrl = `${origin}/i/${season.inviteCode}`;
|
2025-10-14 12:20:36 -07:00
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(inviteUrl);
|
|
|
|
|
setCopied(true);
|
|
|
|
|
toast.success("Invite link copied to clipboard!");
|
|
|
|
|
setTimeout(() => setCopied(false), 2000);
|
2025-10-14 21:24:58 -07:00
|
|
|
} catch {
|
2025-10-14 12:20:36 -07:00
|
|
|
toast.error("Failed to copy invite link");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-07 15:57:51 -08:00
|
|
|
// Pre-compute standings map to avoid O(n²) lookups in render
|
|
|
|
|
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
|
|
|
|
|
|
2026-03-27 20:45:15 -07:00
|
|
|
const isTiedRank = buildTiedRankChecker(standings.map((s) => s.currentRank));
|
|
|
|
|
|
2026-03-07 15:57:51 -08:00
|
|
|
// Pre-compute sorted standings list
|
2026-03-21 09:44:05 -07:00
|
|
|
const sortedTeams = [...teams].toSorted((a, b) => {
|
2026-03-07 15:57:51 -08:00
|
|
|
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
|
|
|
|
|
const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity;
|
|
|
|
|
return rankA - rankB;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Pre-compute sorted sports seasons: active → upcoming → completed,
|
|
|
|
|
// season_standings last within active, then alphabetical by sport name
|
2026-03-21 09:44:05 -07:00
|
|
|
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
|
2026-03-07 15:57:51 -08:00
|
|
|
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";
|
|
|
|
|
|
2025-10-11 00:07:39 -07:00
|
|
|
return (
|
|
|
|
|
<div className="container mx-auto py-8 px-4">
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<h1 className="text-4xl font-bold">{league.name}</h1>
|
2025-10-11 00:29:04 -07:00
|
|
|
<div className="flex gap-2">
|
2026-03-05 10:07:15 -08:00
|
|
|
{myTeam && (
|
2025-10-14 22:04:37 -07:00
|
|
|
<Button variant="outline" asChild>
|
2026-03-05 10:07:15 -08:00
|
|
|
<Link to={`/teams/${myTeam.id}/settings`}>
|
2025-10-14 22:04:37 -07:00
|
|
|
Team Settings
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-10-11 00:29:04 -07:00
|
|
|
{isUserCommissioner && (
|
|
|
|
|
<Button variant="outline" asChild>
|
2025-10-14 22:04:37 -07:00
|
|
|
<Link to={`/leagues/${league.id}/settings`}>
|
|
|
|
|
League Settings
|
|
|
|
|
</Link>
|
2025-10-11 00:29:04 -07:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-10-11 00:07:39 -07:00
|
|
|
</div>
|
|
|
|
|
{season && (
|
|
|
|
|
<p className="text-muted-foreground">
|
2026-03-07 15:57:51 -08:00
|
|
|
{season.year} Season •{" "}
|
2025-10-11 00:07:39 -07:00
|
|
|
<span className="capitalize">
|
|
|
|
|
{season.status.replace("_", " ")}
|
|
|
|
|
</span>
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-27 23:00:42 -08:00
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-10-14 21:54:46 -07:00
|
|
|
<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-03-05 10:07:15 -08:00
|
|
|
{/* Standings Panel - active/completed seasons */}
|
2026-03-07 15:57:51 -08:00
|
|
|
{season && isActiveOrCompleted && (
|
2026-03-05 10:07:15 -08:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle>Standings</CardTitle>
|
2026-03-07 15:57:51 -08:00
|
|
|
<CardDescription>
|
|
|
|
|
{season.status === "completed" ? "Final standings" : "Current season standings"}
|
|
|
|
|
</CardDescription>
|
2026-03-05 10:07:15 -08:00
|
|
|
</div>
|
|
|
|
|
<Button variant="outline" size="sm" asChild>
|
|
|
|
|
<Link to={`/leagues/${league.id}/standings/${season.id}`}>
|
|
|
|
|
Full Standings
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-1">
|
2026-03-07 15:57:51 -08:00
|
|
|
{sortedTeams.map((team) => {
|
|
|
|
|
const standing = standingsMap.get(team.id);
|
2026-03-05 10:07:15 -08:00
|
|
|
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={team.id}
|
|
|
|
|
className="flex items-center gap-3 py-2 border-b last:border-0"
|
|
|
|
|
>
|
|
|
|
|
<div className="w-8 text-center text-sm font-bold text-muted-foreground">
|
2026-03-27 20:45:15 -07:00
|
|
|
{getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false)}
|
2026-03-05 10:07:15 -08:00
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
Display team owner names in standings views (#184)
* Show team name + username in standings, extract TeamNameDisplay component
- Add TeamNameDisplay component that renders team name (as link) with owner username below, matching the league homepage style
- Update StandingsTable to use TeamNameDisplay with owner username shown below team name
- Update league homepage standings section to use TeamNameDisplay
- Add ownerName/teamOwnerId fields to TeamStanding type
- Extend getSeasonStandings to include teamOwnerId from team relation
- Fetch and attach owner display names in the full standings page loader
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
* Address code review: type hygiene, explicit types, parallel fetching, style fix
- Remove teamOwnerId from TeamStanding type (was an internal server concern, not display data)
- Remove teamOwnerId from getSeasonStandings return value
- Add TeamStandingWithChange interface extending TeamStanding with sevenDayRankChange/sevenDayOldRank fields
- Annotate getSevenDayStandingsChange with explicit Promise<TeamStandingWithChange[]> return type
- Re-export TeamStandingWithChange from models/standings.ts
- Standings loader: query teams table directly for ownerIds instead of relying on teamOwnerId in standings data
- Standings loader: parallelize all independent queries (standings, teams, progressionData, seasonComplete, completionPercentage) with Promise.all
- Restore font-medium on StandingsTable team TableCell
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 20:19:28 -07:00
|
|
|
<TeamNameDisplay
|
|
|
|
|
teamName={team.name}
|
|
|
|
|
ownerName={ownerName}
|
|
|
|
|
href={`/leagues/${league.id}/standings/${season.id}/teams/${team.id}`}
|
|
|
|
|
/>
|
2026-03-05 10:07:15 -08:00
|
|
|
</div>
|
|
|
|
|
<div className="text-sm font-medium tabular-nums">
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
{standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts
|
2026-03-05 10:07:15 -08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-14 20:39:51 -08:00
|
|
|
{/* Sports Seasons Section */}
|
2026-03-07 15:57:51 -08:00
|
|
|
{season && sortedSportsSeasons.length > 0 && (
|
2025-11-14 20:39:51 -08:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Sports Seasons</CardTitle>
|
|
|
|
|
<CardDescription>
|
2026-03-07 15:57:51 -08:00
|
|
|
{sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
|
2025-11-14 20:39:51 -08:00
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
2026-03-07 15:57:51 -08:00
|
|
|
{sortedSportsSeasons.map((sportSeason) => (
|
2025-11-14 20:39:51 -08:00
|
|
|
<SportSeasonCard
|
|
|
|
|
key={sportSeason.id}
|
|
|
|
|
leagueId={league.id}
|
|
|
|
|
sportSeasonId={sportSeason.id}
|
|
|
|
|
sportName={sportSeason.sport.name}
|
|
|
|
|
seasonName={sportSeason.name}
|
2026-03-07 15:57:51 -08:00
|
|
|
status={sportSeason.status}
|
2025-11-14 20:39:51 -08:00
|
|
|
scoringPattern={sportSeason.scoringPattern}
|
2026-03-12 10:12:38 -07:00
|
|
|
upcomingParticipantEvents={sportSeason.upcomingParticipantEvents}
|
2025-11-14 20:39:51 -08:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-05 10:07:15 -08:00
|
|
|
{isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && (
|
2025-10-14 21:54:46 -07:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Invite Link</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Share this link to invite people to join your league (
|
|
|
|
|
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""}{" "}
|
|
|
|
|
available)
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
readOnly
|
2026-03-07 15:57:51 -08:00
|
|
|
value={`${origin}/i/${season.inviteCode}`}
|
2025-10-14 21:54:46 -07:00
|
|
|
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>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
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
|
|
|
{/* Draft Order card - shown when order is set, pre_draft/draft */}
|
|
|
|
|
{season && isDraftOrPreDraft && draftSlots.length > 0 && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle>Draft Order</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
{season.status === "pre_draft"
|
|
|
|
|
? "Upcoming draft order"
|
|
|
|
|
: "Current draft order"}
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</div>
|
|
|
|
|
<Button asChild size="sm">
|
|
|
|
|
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
|
|
|
|
|
Enter Draft Room
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{draftSlots.map((slot, index) => {
|
|
|
|
|
const ownerName = slot.team.ownerId
|
|
|
|
|
? ownerMap[slot.team.ownerId]
|
|
|
|
|
: null;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={slot.id}
|
|
|
|
|
className="flex items-center gap-3 py-2 border-b last:border-0"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground font-bold text-xs">
|
|
|
|
|
{index + 1}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<p className="font-medium text-sm truncate">
|
|
|
|
|
{slot.team.name}
|
|
|
|
|
</p>
|
|
|
|
|
{ownerName && (
|
|
|
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
|
|
|
{ownerName}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-05 10:07:15 -08:00
|
|
|
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
|
2026-03-07 15:57:51 -08:00
|
|
|
{season && isDraftOrPreDraft && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Teams</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
{teams.length} team{teams.length !== 1 ? "s" : ""} in this league
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{teams.map((team) => {
|
|
|
|
|
const isOwned = team.ownerId === currentUserId;
|
|
|
|
|
const ownerName = team.ownerId
|
|
|
|
|
? ownerMap[team.ownerId]
|
|
|
|
|
: null;
|
|
|
|
|
return (
|
|
|
|
|
<Card
|
|
|
|
|
key={team.id}
|
|
|
|
|
className={isOwned ? "border-primary" : ""}
|
|
|
|
|
>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="text-lg">{team.name}</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
{team.ownerId ? (
|
|
|
|
|
isOwned ? (
|
|
|
|
|
<span className="text-primary font-medium">
|
|
|
|
|
Your Team
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span>{ownerName || "Unknown Owner"}</span>
|
|
|
|
|
)
|
2025-10-14 21:54:46 -07:00
|
|
|
) : (
|
2026-03-07 15:57:51 -08:00
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
Available
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
2025-10-14 21:54:46 -07:00
|
|
|
</div>
|
2025-10-11 00:07:39 -07:00
|
|
|
|
2025-10-14 21:54:46 -07:00
|
|
|
{/* Right Column - 1/3 width on desktop */}
|
|
|
|
|
<div className="space-y-6">
|
2026-03-12 10:12:38 -07:00
|
|
|
{upcomingCalendarEvents.length > 0 && (
|
2026-03-27 00:49:16 -07:00
|
|
|
<UpcomingCalendarPanel
|
|
|
|
|
events={upcomingCalendarEvents}
|
|
|
|
|
showLeague={false}
|
|
|
|
|
limit={6}
|
|
|
|
|
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
|
|
|
|
|
/>
|
2026-03-12 10:12:38 -07:00
|
|
|
)}
|
2025-10-14 21:54:46 -07:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>League Info</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-2">
|
2025-10-11 00:07:39 -07:00
|
|
|
<div>
|
2025-10-14 21:54:46 -07:00
|
|
|
<p className="text-sm text-muted-foreground">Created</p>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{new Date(league.createdAt).toLocaleDateString()}
|
2025-10-11 00:07:39 -07:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-10-14 21:54:46 -07:00
|
|
|
{season && (
|
2025-10-15 21:50:02 -07:00
|
|
|
<>
|
2026-03-07 15:57:51 -08:00
|
|
|
{isDraftOrPreDraft && (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Teams Filled
|
|
|
|
|
</p>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{teamsWithOwners} of {teams.length}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{isDraftOrPreDraft && (
|
2026-03-05 10:07:15 -08:00
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Draft Order Set
|
2025-10-21 21:36:42 -07:00
|
|
|
</p>
|
2026-03-05 10:07:15 -08:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{isDraftOrderSet ? "Yes" : "No"}
|
|
|
|
|
</p>
|
|
|
|
|
{!isDraftOrderSet && isUserCommissioner && (
|
|
|
|
|
<Button size="sm" variant="outline" asChild>
|
|
|
|
|
<Link to={`/leagues/${league.id}/settings#draft-order`}>
|
|
|
|
|
Set Order
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-10-21 21:36:42 -07:00
|
|
|
</div>
|
2026-03-05 10:07:15 -08:00
|
|
|
)}
|
2025-10-15 21:50:02 -07:00
|
|
|
<div>
|
2025-10-20 15:53:38 -07:00
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Draft Rounds
|
|
|
|
|
</p>
|
2025-10-15 21:50:02 -07:00
|
|
|
<p className="font-medium">{season.draftRounds}</p>
|
|
|
|
|
</div>
|
2026-03-24 17:00:32 -07:00
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Draft Timer Mode
|
|
|
|
|
</p>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{season.draftTimerMode === "standard" ? "Standard Timer" : "Chess Clock"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-10-15 21:50:02 -07:00
|
|
|
<div>
|
2025-10-20 15:53:38 -07:00
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Sports Selected
|
|
|
|
|
</p>
|
2025-10-15 21:50:02 -07:00
|
|
|
<p className="font-medium">{sportsCount}</p>
|
|
|
|
|
</div>
|
2025-10-20 15:53:38 -07:00
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Draft Board</p>
|
|
|
|
|
<Link
|
|
|
|
|
to={`/leagues/${league.id}/draft-board/${season.id}`}
|
2026-02-20 19:26:11 -08:00
|
|
|
className="text-electric hover:underline font-medium"
|
2025-10-20 15:53:38 -07:00
|
|
|
>
|
|
|
|
|
View Draft Board
|
|
|
|
|
</Link>
|
2025-11-13 13:24:03 -08:00
|
|
|
</div>
|
2026-03-07 15:57:51 -08:00
|
|
|
{isActiveOrCompleted && (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Standings</p>
|
|
|
|
|
<Link
|
|
|
|
|
to={`/leagues/${league.id}/standings/${season.id}`}
|
|
|
|
|
className="text-electric hover:underline font-medium"
|
|
|
|
|
>
|
|
|
|
|
View Standings
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-15 21:50:02 -07:00
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Flex Spots</p>
|
|
|
|
|
<p className="font-medium text-primary">
|
|
|
|
|
{Math.max(0, season.draftRounds - sportsCount)}
|
|
|
|
|
</p>
|
2026-03-07 15:57:51 -08:00
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Picks not tied to a specific sport
|
|
|
|
|
</p>
|
2025-10-15 21:50:02 -07:00
|
|
|
</div>
|
2025-10-15 22:02:21 -07:00
|
|
|
{season.draftDateTime && (
|
|
|
|
|
<div>
|
2025-10-20 15:53:38 -07:00
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Draft Date
|
|
|
|
|
</p>
|
2025-10-15 22:02:21 -07:00
|
|
|
<p className="font-medium">
|
|
|
|
|
{format(new Date(season.draftDateTime), "PPP 'at' p")}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-15 21:50:02 -07:00
|
|
|
</>
|
2025-10-14 21:54:46 -07:00
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
2025-10-14 21:54:46 -07:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Commissioners</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
{commissioners.length} commissioner
|
|
|
|
|
{commissioners.length !== 1 ? "s" : ""}
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{commissioners.map((commissioner) => {
|
|
|
|
|
const commissionerName = commissionerMap[commissioner.userId];
|
2026-02-20 08:45:09 -08:00
|
|
|
const hasTeam = teams.some(
|
|
|
|
|
(t) => t.ownerId === commissioner.userId
|
|
|
|
|
);
|
2025-10-14 21:54:46 -07:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={commissioner.id}
|
|
|
|
|
className="py-2 border-b last:border-0"
|
|
|
|
|
>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{commissionerName || "Unknown User"}
|
|
|
|
|
{commissioner.userId === currentUserId && (
|
2025-10-14 22:04:37 -07:00
|
|
|
<span className="ml-2 text-xs text-primary">
|
|
|
|
|
(You)
|
|
|
|
|
</span>
|
2025-10-14 21:54:46 -07:00
|
|
|
)}
|
|
|
|
|
</p>
|
2026-02-20 08:45:09 -08:00
|
|
|
{!hasTeam && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
No team
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2025-10-14 21:54:46 -07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
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.entries.length > 0 && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle>Recent Activity</CardTitle>
|
|
|
|
|
<CardDescription>Recent commissioner actions</CardDescription>
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant="outline" size="sm" asChild>
|
|
|
|
|
<Link to={`/leagues/${league.id}/audit-log`}>View all</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<ul className="space-y-3">
|
|
|
|
|
{recentActivity.entries.map((entry) => (
|
|
|
|
|
<li key={entry.id} className="flex items-start gap-3 text-sm">
|
|
|
|
|
<span className="text-muted-foreground whitespace-nowrap shrink-0">
|
|
|
|
|
{format(new Date(entry.createdAt), "MMM d, HH:mm")}
|
|
|
|
|
</span>
|
|
|
|
|
<span>
|
|
|
|
|
<span className="font-medium">{entry.actorDisplayName ?? entry.actorClerkId}</span>
|
|
|
|
|
{" — "}
|
|
|
|
|
<span className="text-muted-foreground">{formatAuditDetail(entry)}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
2025-10-14 21:54:46 -07:00
|
|
|
</div>
|
2025-10-11 00:07:39 -07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|