Work on standings page.
This commit is contained in:
parent
4d959c704a
commit
744ae2b43e
15 changed files with 19686 additions and 78 deletions
116
app/components/standings/RecentScoresCard.tsx
Normal file
116
app/components/standings/RecentScoresCard.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||||
import { doesLoserAdvance } from "~/models/playoff-match";
|
import { doesLoserAdvance } from "~/models/playoff-match";
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -393,6 +394,7 @@ export async function processMatchResult(
|
||||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
||||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||||
}
|
}
|
||||||
|
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||||
} else {
|
} else {
|
||||||
const config = getRoundConfig(round, bracketTemplateId);
|
const config = getRoundConfig(round, bracketTemplateId);
|
||||||
|
|
||||||
|
|
@ -402,15 +404,37 @@ export async function processMatchResult(
|
||||||
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
|
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
|
||||||
);
|
);
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
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) {
|
} else if (config.winnerFloor === null) {
|
||||||
// Finalization round: winner and loser both get their exact positions.
|
// Finalization round: winner and loser both get their exact positions.
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
|
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 {
|
} else {
|
||||||
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
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
|
* isPartialScore=true means the participant is still alive and this placement
|
||||||
* is their guaranteed minimum floor — it will be replaced as they advance or
|
* is their guaranteed minimum floor — it will be replaced as they advance or
|
||||||
* when they are finally eliminated.
|
* 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(
|
async function upsertParticipantResult(
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
|
@ -443,7 +471,7 @@ async function upsertParticipantResult(
|
||||||
finalPosition: number,
|
finalPosition: number,
|
||||||
db: ReturnType<typeof database>,
|
db: ReturnType<typeof database>,
|
||||||
isPartialScore = false
|
isPartialScore = false
|
||||||
): Promise<void> {
|
): Promise<number | null> {
|
||||||
const existing = await db.query.participantResults.findFirst({
|
const existing = await db.query.participantResults.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.participantResults.participantId, participantId),
|
eq(schema.participantResults.participantId, participantId),
|
||||||
|
|
@ -455,7 +483,7 @@ async function upsertParticipantResult(
|
||||||
// Never un-finalize: if the row is already finalized (isPartialScore=false),
|
// 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
|
// a call with isPartialScore=true must not overwrite it (e.g. a re-run of
|
||||||
// processPlayoffEvent after a manual finalization).
|
// processPlayoffEvent after a manual finalization).
|
||||||
if (!existing.isPartialScore && isPartialScore) return;
|
if (!existing.isPartialScore && isPartialScore) return null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(schema.participantResults)
|
.update(schema.participantResults)
|
||||||
|
|
@ -465,6 +493,7 @@ async function upsertParticipantResult(
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.participantResults.id, existing.id));
|
.where(eq(schema.participantResults.id, existing.id));
|
||||||
|
return existing.finalPosition ?? 0;
|
||||||
} else {
|
} else {
|
||||||
await db.insert(schema.participantResults).values({
|
await db.insert(schema.participantResults).values({
|
||||||
participantId,
|
participantId,
|
||||||
|
|
@ -472,6 +501,7 @@ async function upsertParticipantResult(
|
||||||
finalPosition,
|
finalPosition,
|
||||||
isPartialScore,
|
isPartialScore,
|
||||||
});
|
});
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
258
app/models/team-score-events.ts
Normal file
258
app/models/team-score-events.ts
Normal 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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -421,7 +421,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
|
matchId,
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
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) {
|
for (const match of sortedMatches) {
|
||||||
|
if (!match.winnerId || !match.loserId) continue;
|
||||||
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
|
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
|
||||||
await processMatchResult(
|
await processMatchResult(
|
||||||
{
|
{
|
||||||
round: match.round,
|
round: match.round,
|
||||||
winnerId: match.winnerId ?? "",
|
winnerId: match.winnerId,
|
||||||
loserId: match.loserId ?? "",
|
loserId: match.loserId,
|
||||||
isScoring,
|
isScoring,
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
|
eventId: event.id,
|
||||||
|
eventName: event.name ?? undefined,
|
||||||
|
matchId: match.id,
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,15 @@ import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { Button } from "~/components/ui/button";
|
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 { logger } from "~/lib/logger";
|
||||||
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
|
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
|
||||||
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
||||||
|
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
|
||||||
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
||||||
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user";
|
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";
|
import type { Route } from "./+types/$leagueId.standings.$seasonId";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
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
|
// 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([
|
await Promise.all([
|
||||||
getSevenDayStandingsChange(seasonId, db),
|
getSevenDayStandingsChange(seasonId, db),
|
||||||
db.query.teams.findMany({
|
db.query.teams.findMany({
|
||||||
|
|
@ -83,6 +85,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
getSeasonPointProgression(seasonId, db),
|
getSeasonPointProgression(seasonId, db),
|
||||||
isSeasonComplete(seasonId, db),
|
isSeasonComplete(seasonId, db),
|
||||||
getSeasonCompletionPercentage(seasonId, db),
|
getSeasonCompletionPercentage(seasonId, db),
|
||||||
|
getRecentTeamScoreEvents(seasonId, 10, db),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build teamId -> ownerName map
|
// Build teamId -> ownerName map
|
||||||
|
|
@ -105,6 +108,12 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
|
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Enrich recentScoreEvents with owner names
|
||||||
|
const enrichedScoreEvents = recentScoreEvents.map((event) => ({
|
||||||
|
...event,
|
||||||
|
ownerName: ownerNameByTeamId.get(event.teamId) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
|
|
@ -112,6 +121,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
progressionData,
|
progressionData,
|
||||||
seasonComplete,
|
seasonComplete,
|
||||||
completionPercentage,
|
completionPercentage,
|
||||||
|
recentScoreEvents: enrichedScoreEvents,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error loading standings:", error);
|
logger.error("Error loading standings:", error);
|
||||||
|
|
@ -124,7 +134,19 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeagueStandings() {
|
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 (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
|
@ -157,75 +179,26 @@ export default function LeagueStandings() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Standings Card */}
|
{/* Two-column layout */}
|
||||||
<Card>
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<CardHeader>
|
{/* Left: Standings + Point Progression (2/3 width) */}
|
||||||
<CardTitle>
|
<div className="md:col-span-2 space-y-6">
|
||||||
{seasonComplete ? "Final Standings" : "Current Standings"}
|
<StandingsPreview
|
||||||
</CardTitle>
|
entries={previewEntries}
|
||||||
</CardHeader>
|
description={seasonComplete ? "Final Standings" : "Current Standings"}
|
||||||
<CardContent>
|
/>
|
||||||
{standings.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
{progressionData.chartData.length > 0 && (
|
||||||
<p className="text-lg">No standings data yet.</p>
|
<PointProgressionChart
|
||||||
<p className="text-sm mt-2">
|
chartData={progressionData.chartData}
|
||||||
Standings will appear once participants have results.
|
teams={progressionData.teams}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<StandingsTable
|
|
||||||
standings={standings}
|
|
||||||
leagueId={league.id}
|
|
||||||
seasonId={season.id}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Point Progression Chart */}
|
|
||||||
{progressionData.chartData.length > 0 && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<PointProgressionChart
|
|
||||||
chartData={progressionData.chartData}
|
|
||||||
teams={progressionData.teams}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info Card */}
|
{/* Right: Recent Scores (1/3 width) */}
|
||||||
<Card className="mt-6">
|
<RecentScoresCard events={recentScoreEvents} />
|
||||||
<CardContent className="pt-6">
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
|
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
|
// Users table - synced from Clerk
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
|
|
@ -1295,3 +1295,55 @@ export const commissionerAuditLogRelations = relations(commissionerAuditLog, ({
|
||||||
references: [leagues.id],
|
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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
30
drizzle/0077_lively_wolfpack.sql
Normal file
30
drizzle/0077_lively_wolfpack.sql
Normal 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");
|
||||||
1
drizzle/0078_striped_rick_jones.sql
Normal file
1
drizzle/0078_striped_rick_jones.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "team_score_events" ADD COLUMN "participant_ids" text[];
|
||||||
4
drizzle/0079_wide_trauma.sql
Normal file
4
drizzle/0079_wide_trauma.sql
Normal 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;
|
||||||
5
drizzle/0080_chemical_gorilla_man.sql
Normal file
5
drizzle/0080_chemical_gorilla_man.sql
Normal 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 $$;
|
||||||
4751
drizzle/meta/0077_snapshot.json
Normal file
4751
drizzle/meta/0077_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4757
drizzle/meta/0078_snapshot.json
Normal file
4757
drizzle/meta/0078_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4792
drizzle/meta/0079_snapshot.json
Normal file
4792
drizzle/meta/0079_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4805
drizzle/meta/0080_snapshot.json
Normal file
4805
drizzle/meta/0080_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -540,6 +540,34 @@
|
||||||
"when": 1776226761564,
|
"when": 1776226761564,
|
||||||
"tag": "0076_heavy_zemo",
|
"tag": "0076_heavy_zemo",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue