All three predate the EV fix on this branch and were surfaced by a review
of the full main..HEAD range.
1. reprocess-bracket skipped its wipe exactly when it was needed.
The wipe was guarded on `completed.length > 0`, but clear-bracket
deliberately leaves placements alone and tells the admin to "Run
Reprocess Bracket after rebuilding to clear the placements those
results produced". After clear then regenerate nothing is completed,
so the wipe was skipped and the discarded bracket's finalized
placements survived — and upsertParticipantResult's never-un-finalize
guard then stopped the entry floors and the replay from correcting
them. The advertised recovery path could not work.
The guard was not arbitrary: seasonParticipantResults is keyed by
sports season, not by event, so a season-wide delete takes every
other event's placements with it. Rather than flip the condition,
narrow the delete. New deleteParticipantResultsForParticipants scopes
it to the participants the bracket actually holds, which removes the
collateral damage the guard was defending against, so the delete can
run unconditionally. The participant set was already being computed
further down for the elimination pass; it is now built once and
reused. The qualifying branch keeps its season-wide delete, which is
deliberate and rebuilds via finalizeQualifyingPoints.
2. Banked entry floors could miss teamStandings.totalPoints.
generate-bracket recalculated standings only when `toEliminate` was
empty, assuming markEliminatedAndAnnounce covers every other case. It
does not — it recalculates only when the event is non-qualifying AND
somebody was *newly* eliminated, i.e. had no prior result row. So the
second run of a generation (the first wrote 0 for every non-bracket
participant) recalculated nowhere, and neither did a qualifying event
with teams to eliminate. The floors never reached the standings.
markEliminatedAndAnnounce now returns { markedCount, recalculated }
and the caller drives off that fact instead of re-deriving it, which
also covers the case where the announcement threw — the catch
swallows the error, and a failed recalc is precisely when the
fallback should run.
3. The NBA mobile pager fell back to index geometry.
Its BracketTreePaginated was the only one of five call sites not
forwarding feeders/template, so mobile rendered "TBD" where desktop
rendered "Winner of ...".
Tests: reprocess wipes on a bracket with nothing played, stays scoped to
the bracket, dedupes and skips empty slots, and leaves the qualifying
path alone; generate recalculates in each of the four gaps above and
still does not double-recalculate; and the NBA layout gives its mobile
pane the same slot labels as desktop. Each was confirmed to fail against
the previous behavior.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
import { eq, and, inArray } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
|
|
export type ParticipantResult = typeof schema.seasonParticipantResults.$inferSelect;
|
|
export type ParticipantResultWithParticipant = ParticipantResult & {
|
|
participant: { id: string; name: string } | null;
|
|
};
|
|
export type NewParticipantResult = typeof schema.seasonParticipantResults.$inferInsert;
|
|
|
|
export async function createParticipantResult(
|
|
data: NewParticipantResult
|
|
): Promise<ParticipantResult> {
|
|
const db = database();
|
|
const [result] = await db
|
|
.insert(schema.seasonParticipantResults)
|
|
.values(data)
|
|
.returning();
|
|
return result;
|
|
}
|
|
|
|
export async function createManyParticipantResults(
|
|
data: NewParticipantResult[]
|
|
): Promise<ParticipantResult[]> {
|
|
const db = database();
|
|
return await db
|
|
.insert(schema.seasonParticipantResults)
|
|
.values(data)
|
|
.returning();
|
|
}
|
|
|
|
export async function findParticipantResultById(
|
|
id: string
|
|
): Promise<ParticipantResult | undefined> {
|
|
const db = database();
|
|
return await db.query.seasonParticipantResults.findFirst({
|
|
where: eq(schema.seasonParticipantResults.id, id),
|
|
with: {
|
|
participant: true,
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findParticipantResultByParticipantId(
|
|
participantId: string
|
|
): Promise<ParticipantResult | undefined> {
|
|
const db = database();
|
|
return await db.query.seasonParticipantResults.findFirst({
|
|
where: eq(schema.seasonParticipantResults.participantId, participantId),
|
|
with: {
|
|
participant: true,
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findParticipantResultsBySportsSeasonId(
|
|
sportsSeasonId: string
|
|
): Promise<ParticipantResultWithParticipant[]> {
|
|
const db = database();
|
|
return await db.query.seasonParticipantResults.findMany({
|
|
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
|
orderBy: (results, { asc }) => [asc(results.finalPosition)],
|
|
with: {
|
|
participant: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function updateParticipantResult(
|
|
id: string,
|
|
data: Partial<NewParticipantResult>
|
|
): Promise<ParticipantResult> {
|
|
const db = database();
|
|
const [result] = await db
|
|
.update(schema.seasonParticipantResults)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(schema.seasonParticipantResults.id, id))
|
|
.returning();
|
|
return result;
|
|
}
|
|
|
|
export async function deleteParticipantResult(id: string): Promise<void> {
|
|
const db = database();
|
|
await db.delete(schema.seasonParticipantResults).where(eq(schema.seasonParticipantResults.id, id));
|
|
}
|
|
|
|
export async function deleteParticipantResultsBySportsSeasonId(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<void> {
|
|
const db = providedDb || database();
|
|
await db
|
|
.delete(schema.seasonParticipantResults)
|
|
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
|
}
|
|
|
|
/**
|
|
* Delete the results of specific participants within one sports season.
|
|
*
|
|
* The season-wide delete above is too blunt for a single bracket: results are keyed by
|
|
* sports season, not by event, so wiping the season takes every other event's placements
|
|
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
|
|
* rebuild that bracket from scratch while leaving the rest of the season alone.
|
|
*
|
|
* No-ops on an empty id list — `inArray` with no values is not a valid SQL predicate.
|
|
*/
|
|
export async function deleteParticipantResultsForParticipants(
|
|
sportsSeasonId: string,
|
|
participantIds: string[],
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<void> {
|
|
if (participantIds.length === 0) return;
|
|
const db = providedDb || database();
|
|
await db
|
|
.delete(schema.seasonParticipantResults)
|
|
.where(
|
|
and(
|
|
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
|
inArray(schema.seasonParticipantResults.participantId, participantIds)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set result for a participant in a sports season
|
|
* Points are calculated on-demand based on each fantasy league's scoring rules
|
|
*/
|
|
export async function setParticipantResult(
|
|
participantId: string,
|
|
sportsSeasonId: string,
|
|
finalPosition: number,
|
|
qualifyingPoints?: number,
|
|
notes?: string
|
|
): Promise<ParticipantResult> {
|
|
const db = database();
|
|
|
|
// Check if result already exists
|
|
const existing = await db.query.seasonParticipantResults.findFirst({
|
|
where: and(
|
|
eq(schema.seasonParticipantResults.participantId, participantId),
|
|
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId)
|
|
),
|
|
});
|
|
|
|
if (existing) {
|
|
// Update existing result
|
|
return await updateParticipantResult(existing.id, {
|
|
finalPosition,
|
|
qualifyingPoints: qualifyingPoints?.toString(),
|
|
notes,
|
|
});
|
|
} else {
|
|
// Create new result
|
|
return await createParticipantResult({
|
|
participantId,
|
|
sportsSeasonId,
|
|
finalPosition,
|
|
qualifyingPoints: qualifyingPoints?.toString(),
|
|
notes,
|
|
});
|
|
}
|
|
}
|