brackt/app/models/draft-pick.ts
Chris Parsons 9ed0282fd0
New design (#309)
* Redesign home page with new layout and component system

- Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack
- LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar
- MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader
- UpcomingEventsCard: vertical timeline with grouped multi-league events
- Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants
- Button default variant updated to green→cyan gradient
- Navbar: plain nav links with gradient hover, support/admin icon buttons
- Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements
- Storybook stories for all new components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Responsive league row layout and mobile polish

- League rows stack avatar+name on top, stats full-width below on mobile
- Stats spread to right side on sm+ screens with border separator on mobile
- Tighter padding on mobile (px-3/py-3), full padding on sm+
- Card headers and content use px-3 sm:px-6 to reduce mobile gutters
- Two-column home layout deferred to lg breakpoint (tablet gets stacked)
- Active leagues sorted by completion percentage descending
- Default rank 1 / 0 points for active leagues with no scoring events yet
- Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators
- Remove dead StatDivider className prop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Improve claude file.

* Add StandingsPreview card component with podium row styling

- New StandingsPreview component with gold/silver/bronze row tints for
  top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points)
  with rank and 7-day point change indicators
- Fix GradientIcon in Storybook by adding BracktGradients decorator to
  preview.tsx (renamed from .ts to support JSX)
- Fix degenerate SVG gradient on horizontal strokes by switching
  BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space
  coordinates (0→24)
- Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only
  fix was sufficient once gradientUnits was corrected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update components on league homepage.

* Finish up league page styling.

* Work on standings page.

* Add story for RecentScoresCard

* Update Point Progression Chart.

* Sort point progression legend by ranking and add team links to standings rows

* Fix standings discrepancy on change.

* Create draft cell component.

* Update draft board page

* Draft room improvements.

* Update some draft room styling.

* Fix context menu missing.

* Move tab navigation and autodraft to header row, narrow sidebar

* Virtualize available participants list, memoize draft room props

Adds @tanstack/react-virtual to replace separate mobile/desktop lists
with a single unified virtual scroll loop. Also memoizes miniDraftGrid
and availableParticipantsSectionProps, and switches pick lookup from
Array.find to a Map for O(1) access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update draft room UI.

* More draft room fixes.

* Draft room tweaks.

* Fix Rosters page.

* Queue Section fixes.

* Mobile Draft fixes.

* Fix draft board page.

* Create bracket look.

* Bracket work.

* Finish bracket page.

* Homepage initial styling

* homepage copy

* Add privacy policy. Fixes #88.

* how to play copy

* rules copy

* Fix brackets on homepage.

* Add footer to website.

* Glow on dots.

* Landing page copy.

* Fix sidebar.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:14:55 -07:00

329 lines
10 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
export async function createDraftPick(data: {
seasonId: string;
teamId: string;
participantId: string;
pickNumber: number;
round: number;
pickInRound: number;
pickedByUserId: string;
pickedByType: "owner" | "commissioner" | "auto";
timeUsed: number;
}) {
const db = database();
const [pick] = await db.insert(schema.draftPicks).values(data).returning();
return pick;
}
export async function getDraftPicks(seasonId: string) {
const db = database();
return await db
.select()
.from(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(schema.draftPicks.pickNumber);
}
export async function getDraftPickByNumber(seasonId: string, pickNumber: number) {
const db = database();
const [pick] = await db
.select()
.from(schema.draftPicks)
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.pickNumber, pickNumber)
)
);
return pick;
}
export async function getTeamDraftPicks(teamId: string) {
const db = database();
return await db
.select()
.from(schema.draftPicks)
.where(eq(schema.draftPicks.teamId, teamId))
.orderBy(schema.draftPicks.pickNumber);
}
export async function isParticipantDrafted(seasonId: string, participantId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
const [pick] = await db
.select()
.from(schema.draftPicks)
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.participantId, participantId)
)
);
return !!pick;
}
/**
* Get a team's drafted participants grouped by sports season.
* Returns a map of sportsSeasonId → [{id, name}].
*/
export async function getDraftedParticipantsBySportsSeason(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Map<string, Array<{ id: string; name: string }>>> {
const db = providedDb || database();
const results = await db
.select({
sportsSeasonId: schema.participants.sportsSeasonId,
participantId: schema.participants.id,
participantName: schema.participants.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
)
.where(
and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
)
);
const map = new Map<string, Array<{ id: string; name: string }>>();
for (const row of results) {
if (!map.has(row.sportsSeasonId)) {
map.set(row.sportsSeasonId, []);
}
map.get(row.sportsSeasonId)?.push({ id: row.participantId, name: row.participantName });
}
return map;
}
export interface DraftedParticipantWithPoints {
id: string;
name: string;
/** Fantasy league points earned (position → scoring rules). null = no result yet. */
earnedPoints: number | null;
/** Accumulated qualifying points for qualifying_points sports. null for other sports. */
currentQP: number | null;
}
/**
* Get a team's drafted participants with their current earned points, grouped by
* sports season. Used for the league home summary card.
*
* - playoff_bracket / season_standings: earnedPoints = finalPosition → scoring rules
* - qualifying_points (active): currentQP = accumulated QP from participantQualifyingTotals
* - qualifying_points (finalized): earnedPoints = finalPosition → scoring rules (participantResults written)
* - No EV / projected points — actual earned only.
*/
export async function getDraftedParticipantsWithPoints(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Map<string, DraftedParticipantWithPoints[]>> {
const db = providedDb || database();
const scoringRules = await getScoringRules(seasonId, db);
if (!scoringRules) return new Map();
// One query: picks → participant → sportsSeason (scoringPattern) + results (position)
const picks = await db.query.draftPicks.findMany({
where: and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
),
columns: {},
with: {
participant: {
columns: { id: true, name: true, sportsSeasonId: true },
with: {
sportsSeason: { columns: { id: true, scoringPattern: true } },
results: { columns: { finalPosition: true }, limit: 1 },
},
},
},
});
// Collect unique sportsSeasonIds per scoring pattern
const bracketSeasonIds = new Set<string>();
const qpSeasonIds = new Set<string>();
const qpParticipantIds = new Set<string>();
for (const pick of picks) {
const pattern = pick.participant.sportsSeason.scoringPattern;
const ssId = pick.participant.sportsSeasonId;
if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId);
if (pattern === "qualifying_points") {
qpSeasonIds.add(ssId);
qpParticipantIds.add(pick.participant.id);
}
}
// Batch-fetch bracket template IDs (one per sports season)
const bracketTemplateMap = new Map<string, string | null>();
if (bracketSeasonIds.size > 0) {
const events = await db.query.scoringEvents.findMany({
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
columns: { sportsSeasonId: true, bracketTemplateId: true },
});
for (const ev of events) {
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
}
}
}
// Batch-fetch QP totals for qualifying_points participants
const qpMap = new Map<string, number>(); // participantId → totalQP
if (qpParticipantIds.size > 0 && qpSeasonIds.size > 0) {
const totals = await db.query.participantQualifyingTotals.findMany({
where: and(
inArray(schema.participantQualifyingTotals.participantId, [...qpParticipantIds]),
inArray(schema.participantQualifyingTotals.sportsSeasonId, [...qpSeasonIds])
),
columns: { participantId: true, totalQualifyingPoints: true },
});
for (const row of totals) {
qpMap.set(row.participantId, parseFloat(row.totalQualifyingPoints));
}
}
// Assemble result grouped by sportsSeasonId
const result = new Map<string, DraftedParticipantWithPoints[]>();
for (const pick of picks) {
const { id, name, sportsSeasonId } = pick.participant;
const pattern = pick.participant.sportsSeason.scoringPattern;
const resultRow = pick.participant.results[0] ?? null;
let earnedPoints: number | null = null;
let currentQP: number | null = null;
if (pattern === "qualifying_points" && (resultRow?.finalPosition === null || resultRow?.finalPosition === undefined)) {
// Active QP season: show accumulated qualifying points
currentQP = qpMap.get(id) ?? null;
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
// Finalized result for any pattern (including finalized QP seasons)
earnedPoints = pattern === "playoff_bracket"
? calculateBracketPoints(
resultRow.finalPosition,
scoringRules,
bracketTemplateMap.get(sportsSeasonId) ?? null
)
: calculateFantasyPoints(resultRow.finalPosition, scoringRules);
}
const arr = result.get(sportsSeasonId) ?? [];
result.set(sportsSeasonId, arr);
arr.push({ id, name, earnedPoints, currentQP });
}
return result;
}
export async function deleteAllDraftPicks(seasonId: string) {
const db = database();
await db
.delete(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId));
}
/**
* Get all draft picks for a season with participant and sport information
* Used for draft eligibility calculations
*/
export async function getDraftPicksWithSports(seasonId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
const results = await db
.select({
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.participants.id,
participantName: schema.participants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(schema.draftPicks.pickNumber);
// Transform to expected format
return results.map((r) => ({
teamId: r.teamId,
participant: {
id: r.participantId,
sport: {
id: r.sportId,
name: r.sportName,
},
},
}));
}
/**
* Get team's draft picks with participant and sport information
*/
export async function getTeamDraftPicksWithSports(teamId: string, seasonId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
const results = await db
.select({
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.participants.id,
participantName: schema.participants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
)
)
.orderBy(schema.draftPicks.pickNumber);
// Transform to expected format
return results.map((r) => ({
teamId: r.teamId,
participant: {
id: r.participantId,
sport: {
id: r.sportId,
name: r.sportName,
},
},
}));
}