Work on standings page.

This commit is contained in:
Chris Parsons 2026-04-15 22:45:39 -07:00
parent a88a12b790
commit a320f5f854
15 changed files with 19686 additions and 78 deletions

View file

@ -0,0 +1,116 @@
import { formatDistanceToNow } from "date-fns";
import { TrendingUp } from "lucide-react";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { BRACKT_GRADIENT } from "~/lib/brand";
interface ScoreEventEntry {
id: string;
teamId: string;
teamName: string;
ownerName: string | null;
scoringEventId: string | null;
scoringEventName: string | null;
sportName: string | null;
pointsDelta: string;
occurredAt: Date | string;
participants: Array<{ id: string; name: string }>;
}
interface RecentScoresCardProps {
events: ScoreEventEntry[];
}
function TimelineDot({ isRecent }: { isRecent: boolean }) {
return (
<div
suppressHydrationWarning
className={`relative z-10 mt-1 h-2.5 w-2.5 shrink-0 rounded-full${isRecent ? "" : " border-2 border-border bg-background"}`}
style={isRecent ? { background: BRACKT_GRADIENT } : undefined}
/>
);
}
function ScoreRow({
event,
isLast,
}: {
event: ScoreEventEntry;
isLast: boolean;
}) {
const pts = parseFloat(event.pointsDelta);
const displayPts = Number.isInteger(pts) ? pts.toString() : pts.toFixed(1);
const managerName = event.ownerName ?? event.teamName;
const isRecent = Date.now() - new Date(event.occurredAt).getTime() < 24 * 60 * 60 * 1000;
return (
<div className="flex gap-3 min-w-0">
{/* Timeline spine */}
<div className="flex flex-col items-center">
<TimelineDot isRecent={isRecent} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* Content row: points box + text */}
<div className={`flex gap-3 flex-1 min-w-0 ${isLast ? "" : "pb-6"}`}>
{/* Points box — square, no rounded corners */}
<div className="shrink-0 w-10 h-10 flex items-center justify-center bg-black text-white font-bold text-sm leading-none self-start">
+{displayPts}
</div>
{/* Text content */}
<div className="flex flex-col gap-0.5 min-w-0">
{/* Date */}
<div className="text-xs text-muted-foreground/60" suppressHydrationWarning>
{formatDistanceToNow(new Date(event.occurredAt), { addSuffix: true })}
</div>
{/* Manager name */}
<div className="text-sm font-bold text-foreground leading-tight">
{managerName}
</div>
{/* Sport · participant name(s) */}
<div className="text-xs text-muted-foreground">
{[
event.sportName,
event.participants.length > 0
? event.participants.map((p) => p.name).join(", ")
: null,
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
</div>
</div>
);
}
export function RecentScoresCard({ events }: RecentScoresCardProps) {
return (
<div>
{/* Header */}
<div className="flex items-center gap-2 mb-6">
<GradientIcon icon={TrendingUp} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Recent Scores</span>
</div>
{/* Timeline */}
{events.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No recent scores yet they&apos;ll appear here as events complete.
</p>
) : (
<div>
{events.map((event, index) => (
<ScoreRow
key={event.id}
event={event}
isLast={index === events.length - 1}
/>
))}
</div>
)}
</div>
);
}

View file

@ -9,6 +9,7 @@ import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { doesLoserAdvance } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger";
/**
@ -393,6 +394,7 @@ export async function processMatchResult(
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
}
// Non-scoring round wins are not surfaced in the Recent Scores feed.
} else {
const config = getRoundConfig(round, bracketTemplateId);
@ -402,15 +404,37 @@ export async function processMatchResult(
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
);
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
if (oldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: winnerId, sportsSeasonId, oldFloor, newFloor: 5,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
} else if (config.winnerFloor === null) {
// Finalization round: winner and loser both get their exact positions.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db);
const winnerPosition = config.winnerPosition ?? 1;
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, winnerPosition, db);
if (oldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: winnerId, sportsSeasonId, oldFloor, newFloor: winnerPosition,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
} else {
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
if (oldFloor !== null && matchId && eventId) {
await recordMatchScoreEvents(
{ participantId: winnerId, sportsSeasonId, oldFloor, newFloor: config.winnerFloor,
bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null },
db
);
}
}
}
@ -431,11 +455,15 @@ export async function processMatchResult(
}
/**
* Helper to upsert participant result
* Helper to upsert participant result.
*
* isPartialScore=true means the participant is still alive and this placement
* is their guaranteed minimum floor it will be replaced as they advance or
* when they are finally eliminated.
*
* Returns the previous finalPosition (or 0 if this is a new row), so callers
* can compute exact point deltas. Returns null when the update was skipped
* (i.e. trying to set a provisional floor on an already-finalized participant).
*/
async function upsertParticipantResult(
participantId: string,
@ -443,7 +471,7 @@ async function upsertParticipantResult(
finalPosition: number,
db: ReturnType<typeof database>,
isPartialScore = false
): Promise<void> {
): Promise<number | null> {
const existing = await db.query.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
@ -455,7 +483,7 @@ async function upsertParticipantResult(
// Never un-finalize: if the row is already finalized (isPartialScore=false),
// a call with isPartialScore=true must not overwrite it (e.g. a re-run of
// processPlayoffEvent after a manual finalization).
if (!existing.isPartialScore && isPartialScore) return;
if (!existing.isPartialScore && isPartialScore) return null;
await db
.update(schema.participantResults)
@ -465,6 +493,7 @@ async function upsertParticipantResult(
updatedAt: new Date(),
})
.where(eq(schema.participantResults.id, existing.id));
return existing.finalPosition ?? 0;
} else {
await db.insert(schema.participantResults).values({
participantId,
@ -472,6 +501,7 @@ async function upsertParticipantResult(
finalPosition,
isPartialScore,
});
return 0;
}
}

View file

@ -0,0 +1,258 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray, desc, sql, and } from "drizzle-orm";
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
import { logger } from "~/lib/logger";
/**
* Low-level primitive: writes one team_score_events row with an explicit pointsDelta.
* participantIds are captured at write time so attribution is accurate regardless
* of future match results.
*
* When matchId is provided (bracket sports), one row is written per match using
* a partial unique index on (teamId, seasonId, matchId). When matchId is absent
* (non-bracket fallback), one row per (teamId, seasonId, scoringEventId) is used.
*
* For bracket sports, prefer calling recordMatchScoreEvents instead it computes
* the exact per-season delta from scoring rules rather than requiring the caller
* to supply a pre-computed pointsDelta.
*/
export async function recordTeamScoreEvent(
params: {
teamId: string;
seasonId: string;
scoringEventId: string;
scoringEventName: string | null;
sportName: string | null;
participantIds: string[];
pointsDelta: number;
occurredAt?: Date;
matchId?: string;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const values = {
teamId: params.teamId,
seasonId: params.seasonId,
scoringEventId: params.scoringEventId,
scoringEventName: params.scoringEventName,
sportName: params.sportName,
matchId: params.matchId ?? null,
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
occurredAt: params.occurredAt ?? new Date(),
};
if (params.matchId) {
// Per-match path: unique on (teamId, seasonId, matchId) WHERE matchId IS NOT NULL
await db
.insert(schema.teamScoreEvents)
.values(values)
.onConflictDoUpdate({
target: [
schema.teamScoreEvents.teamId,
schema.teamScoreEvents.seasonId,
schema.teamScoreEvents.matchId,
],
targetWhere: sql`match_id IS NOT NULL`,
set: {
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
},
});
} else {
// Event-level fallback: unique on (teamId, seasonId, scoringEventId) WHERE matchId IS NULL
await db
.insert(schema.teamScoreEvents)
.values(values)
.onConflictDoUpdate({
target: [
schema.teamScoreEvents.teamId,
schema.teamScoreEvents.seasonId,
schema.teamScoreEvents.scoringEventId,
],
targetWhere: sql`match_id IS NULL`,
set: {
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
},
});
}
}
/**
* Records a score event for every fantasy season that uses the given sports season,
* attributing the exact point delta to the specific match winner.
*
* Called from processMatchResult immediately after upsertParticipantResult sets the
* winner's new floor, so the delta is computed from the exact position change rather
* than from aggregate before/after standings totals.
*
* oldFloor is 0 when the participant had no prior result row (first match win).
*/
export async function recordMatchScoreEvents(
params: {
participantId: string;
sportsSeasonId: string;
oldFloor: number;
newFloor: number;
bracketTemplateId: string | null;
matchId: string;
eventId: string;
eventName: string | null;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// Fetch sport name for display (one query, shared across all seasons)
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
with: { sport: { columns: { name: true } } },
});
const sportName = sportsSeason?.sport?.name ?? null;
// All fantasy seasons that include this sports season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
columns: { seasonId: true },
});
if (seasonSports.length === 0) return;
const seasonIds = seasonSports.map((ss) => ss.seasonId);
// Batch fetch: which team in each season drafted this participant?
const picks = await db.query.draftPicks.findMany({
where: and(
inArray(schema.draftPicks.seasonId, seasonIds),
eq(schema.draftPicks.participantId, params.participantId)
),
columns: { teamId: true, seasonId: true },
});
if (picks.length === 0) return;
const teamBySeasonId = new Map(picks.map((p) => [p.seasonId, p.teamId]));
// Batch fetch scoring rules for all seasons in one query
const seasonRows = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
columns: {
id: true,
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
pointsFor7th: true, pointsFor8th: true,
},
});
const rulesBySeasonId = new Map<string, ScoringRules>(
seasonRows.map((s) => [s.id, {
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
}])
);
for (const seasonId of seasonIds) {
const teamId = teamBySeasonId.get(seasonId);
if (!teamId) continue;
const rules = rulesBySeasonId.get(seasonId);
if (!rules) continue;
const delta =
calculateBracketPoints(params.newFloor, rules, params.bracketTemplateId) -
calculateBracketPoints(params.oldFloor, rules, params.bracketTemplateId);
if (delta <= 0) continue;
try {
await recordTeamScoreEvent(
{
teamId,
seasonId,
scoringEventId: params.eventId,
scoringEventName: params.eventName,
sportName,
participantIds: [params.participantId],
pointsDelta: delta,
matchId: params.matchId,
},
db
);
} catch (err) {
logger.error(
`[TeamScoreEvents] Failed to record match score event for team ${teamId} match ${params.matchId}:`,
err
);
}
}
}
export interface TeamScoreEventEntry {
id: string;
teamId: string;
teamName: string;
scoringEventId: string | null;
scoringEventName: string | null;
sportName: string | null;
pointsDelta: string;
occurredAt: Date;
participants: Array<{ id: string; name: string }>;
}
/**
* Returns the most recent scoring events for a league season, ordered by
* occurredAt DESC. Participant names are fetched from the stored participantIds.
*/
export async function getRecentTeamScoreEvents(
seasonId: string,
limit = 10,
providedDb?: ReturnType<typeof database>
): Promise<TeamScoreEventEntry[]> {
const db = providedDb || database();
const rows = await db.query.teamScoreEvents.findMany({
where: eq(schema.teamScoreEvents.seasonId, seasonId),
with: {
team: { columns: { id: true, name: true } },
},
orderBy: [desc(schema.teamScoreEvents.occurredAt)],
limit,
});
if (rows.length === 0) return [];
// Batch-fetch participant names for all stored participant IDs
const allParticipantIds = [
...new Set(rows.flatMap((r) => r.participantIds ?? [])),
];
const participantNameById = new Map<string, string>();
if (allParticipantIds.length > 0) {
const participantRows = await db.query.participants.findMany({
where: inArray(schema.participants.id, allParticipantIds),
columns: { id: true, name: true },
});
for (const p of participantRows) {
participantNameById.set(p.id, p.name);
}
}
return rows.map((row) => ({
id: row.id,
teamId: row.teamId,
teamName: row.team.name,
scoringEventId: row.scoringEventId,
scoringEventName: row.scoringEventName,
sportName: row.sportName,
pointsDelta: row.pointsDelta,
occurredAt: row.occurredAt,
participants: (row.participantIds ?? [])
.map((id) => {
const name = participantNameById.get(id);
return name ? { id, name } : null;
})
.filter((p): p is { id: string; name: string } => p !== null),
}));
}

View file

@ -421,7 +421,9 @@ export async function action({ request, params }: Route.ActionArgs) {
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
@ -615,15 +617,19 @@ export async function action({ request, params }: Route.ActionArgs) {
});
for (const match of sortedMatches) {
if (!match.winnerId || !match.loserId) continue;
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
await processMatchResult(
{
round: match.round,
winnerId: match.winnerId ?? "",
loserId: match.loserId ?? "",
winnerId: match.winnerId,
loserId: match.loserId,
isScoring,
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId: match.id,
skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
}

View file

@ -4,13 +4,15 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StandingsTable } from "~/components/standings/StandingsTable";
import { logger } from "~/lib/logger";
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user";
import { StandingsPreview } from "~/components/league/StandingsPreview";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
import type { Route } from "./+types/$leagueId.standings.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -73,7 +75,7 @@ export async function loader(args: Route.LoaderArgs) {
}
// Fetch standings, teams (for owner lookup), and independent data in parallel
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage] =
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage, recentScoreEvents] =
await Promise.all([
getSevenDayStandingsChange(seasonId, db),
db.query.teams.findMany({
@ -83,6 +85,7 @@ export async function loader(args: Route.LoaderArgs) {
getSeasonPointProgression(seasonId, db),
isSeasonComplete(seasonId, db),
getSeasonCompletionPercentage(seasonId, db),
getRecentTeamScoreEvents(seasonId, 10, db),
]);
// Build teamId -> ownerName map
@ -105,6 +108,12 @@ export async function loader(args: Route.LoaderArgs) {
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
}));
// Enrich recentScoreEvents with owner names
const enrichedScoreEvents = recentScoreEvents.map((event) => ({
...event,
ownerName: ownerNameByTeamId.get(event.teamId) ?? null,
}));
return {
league,
season,
@ -112,6 +121,7 @@ export async function loader(args: Route.LoaderArgs) {
progressionData,
seasonComplete,
completionPercentage,
recentScoreEvents: enrichedScoreEvents,
};
} catch (error) {
logger.error("Error loading standings:", error);
@ -124,7 +134,19 @@ export async function loader(args: Route.LoaderArgs) {
}
export default function LeagueStandings() {
const { league, season, standings, progressionData, seasonComplete, completionPercentage } = useLoaderData<typeof loader>();
const { league, season, standings, progressionData, seasonComplete, completionPercentage, recentScoreEvents } = useLoaderData<typeof loader>();
const isTied = buildTiedRankChecker(standings.map((s) => s.currentRank));
const previewEntries = standings.map((s) => ({
teamId: s.teamId,
teamName: s.teamName,
ownerName: s.ownerName,
displayRank: getDisplayRank(s, standings.length, isTied(s.currentRank)),
currentRank: s.currentRank,
points: s.totalPoints,
rankChange: s.rankChange,
pointChange: s.sevenDayPointChange,
}));
return (
<div className="container mx-auto py-8 px-4">
@ -157,75 +179,26 @@ export default function LeagueStandings() {
</div>
</div>
{/* Standings Card */}
<Card>
<CardHeader>
<CardTitle>
{seasonComplete ? "Final Standings" : "Current Standings"}
</CardTitle>
</CardHeader>
<CardContent>
{standings.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg">No standings data yet.</p>
<p className="text-sm mt-2">
Standings will appear once participants have results.
</p>
</div>
) : (
<StandingsTable
standings={standings}
leagueId={league.id}
seasonId={season.id}
{/* Two-column layout */}
<div className="grid gap-6 md:grid-cols-3">
{/* Left: Standings + Point Progression (2/3 width) */}
<div className="md:col-span-2 space-y-6">
<StandingsPreview
entries={previewEntries}
description={seasonComplete ? "Final Standings" : "Current Standings"}
/>
)}
</CardContent>
</Card>
{/* Point Progression Chart */}
{progressionData.chartData.length > 0 && (
<div className="mt-6">
<PointProgressionChart
chartData={progressionData.chartData}
teams={progressionData.teams}
/>
</div>
)}
</div>
{/* Info Card */}
<Card className="mt-6">
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground space-y-2">
<p>
<strong>Projected Points:</strong> Shows actual points from finished
participants plus expected value (EV) from remaining participants based
on their probability distributions. The "+X.X EV" indicates how many
additional points are expected from unfinished participants.
</p>
<p>
<strong>Tiebreaker Rules:</strong> Teams are ranked by total
points. If tied, the team with more 1st place finishes ranks
higher. If still tied, 2nd place finishes are compared, then 3rd,
and so on through 8th place.
</p>
<p>
<strong>Movement Indicators:</strong> Arrows (/) next to rank show rank changes
compared to 7 days ago.
</p>
<p>
<strong>7-Day Change:</strong> Shows points earned and rank movement
over the past 7 days.
</p>
{progressionData.chartData.length > 0 && (
<p>
<strong>Point Progression Chart:</strong> Visualizes how each team's
total points evolved over time based on {progressionData.chartData.length} day{progressionData.chartData.length !== 1 ? 's' : ''} of snapshot data.
{seasonComplete && " Use this to see how the final standings developed throughout the season."}
</p>
)}
{/* Right: Recent Scores (1/3 width) */}
<RecentScoresCard events={recentScoreEvents} />
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
// Users table - synced from Clerk
export const users = pgTable("users", {
@ -1295,3 +1295,55 @@ export const commissionerAuditLogRelations = relations(commissionerAuditLog, ({
references: [leagues.id],
}),
}));
// ─── Team Score Events ─────────────────────────────────────────────────────────
// Records each time a team's total fantasy points increase as a result of a
// scoring event completing. Used for the "Recent Scores" feed on the standings
// page. One row per team per match (bracket sports) or per event (fallback).
// Participant IDs are stored at write time so attribution is accurate regardless
// of future match results.
export const teamScoreEvents = pgTable("team_score_events", {
id: uuid("id").primaryKey().defaultRandom(),
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
scoringEventId: uuid("scoring_event_id")
.references(() => scoringEvents.id, { onDelete: "set null" }),
scoringEventName: varchar("scoring_event_name", { length: 255 }),
sportName: varchar("sport_name", { length: 255 }),
// Set for bracket sports (one row per match); NULL for event-level fallback rows.
matchId: uuid("match_id").references(() => playoffMatches.id, { onDelete: "set null" }),
// Participant IDs captured at write time — the specific drafted participants
// whose win/advancement caused this score change.
participantIds: text("participant_ids").array(),
pointsDelta: decimal("points_delta", { precision: 10, scale: 2 }).notNull(),
occurredAt: timestamp("occurred_at").defaultNow().notNull(),
}, (t) => ({
// Per-match rows: one row per (team, season, match)
uniqueTeamSeasonMatch: uniqueIndex("team_score_events_match_unique")
.on(t.teamId, t.seasonId, t.matchId)
.where(sql`match_id IS NOT NULL`),
// Event-level fallback rows: one row per (team, season, event) when no match data
uniqueTeamSeasonEvent: uniqueIndex("team_score_events_event_unique")
.on(t.teamId, t.seasonId, t.scoringEventId)
.where(sql`match_id IS NULL`),
}));
export const teamScoreEventsRelations = relations(teamScoreEvents, ({ one }) => ({
team: one(teams, {
fields: [teamScoreEvents.teamId],
references: [teams.id],
}),
season: one(seasons, {
fields: [teamScoreEvents.seasonId],
references: [seasons.id],
}),
scoringEvent: one(scoringEvents, {
fields: [teamScoreEvents.scoringEventId],
references: [scoringEvents.id],
}),
}));

View file

@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS "team_score_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL,
"season_id" uuid NOT NULL,
"scoring_event_id" uuid,
"scoring_event_name" varchar(255),
"sport_name" varchar(255),
"points_delta" numeric(10, 2) NOT NULL,
"occurred_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "team_score_events" ADD CONSTRAINT "team_score_events_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "team_score_events" ADD CONSTRAINT "team_score_events_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "team_score_events" ADD CONSTRAINT "team_score_events_scoring_event_id_scoring_events_id_fk" FOREIGN KEY ("scoring_event_id") REFERENCES "public"."scoring_events"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "team_score_events_unique" ON "team_score_events" USING btree ("team_id","season_id","scoring_event_id");

View file

@ -0,0 +1 @@
ALTER TABLE "team_score_events" ADD COLUMN "participant_ids" text[];

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS "team_score_events_unique";--> statement-breakpoint
ALTER TABLE "team_score_events" ADD COLUMN "match_id" uuid;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "team_score_events_match_unique" ON "team_score_events" USING btree ("team_id","season_id","match_id") WHERE match_id IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "team_score_events_event_unique" ON "team_score_events" USING btree ("team_id","season_id","scoring_event_id") WHERE match_id IS NULL;

View file

@ -0,0 +1,5 @@
DO $$ BEGIN
ALTER TABLE "team_score_events" ADD CONSTRAINT "team_score_events_match_id_playoff_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."playoff_matches"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -540,6 +540,34 @@
"when": 1776226761564,
"tag": "0076_heavy_zemo",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1776312815334,
"tag": "0077_lively_wolfpack",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1776314387095,
"tag": "0078_striped_rick_jones",
"breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1776316296907,
"tag": "0079_wide_trauma",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1776318182740,
"tag": "0080_chemical_gorilla_man",
"breakpoints": true
}
]
}