2025-10-28 23:50:50 -07:00
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
import * as schema from "~/database/schema";
|
2026-03-21 09:44:05 -07:00
|
|
|
import { eq, and } from "drizzle-orm";
|
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 type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
|
2026-03-17 14:00:32 -07:00
|
|
|
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
2026-03-21 13:41:39 -07:00
|
|
|
import { logger } from "~/lib/logger";
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
// Re-export types from shared types file
|
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
|
|
|
export type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current standings for a season
|
|
|
|
|
*/
|
|
|
|
|
export async function getSeasonStandings(
|
|
|
|
|
seasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<TeamStanding[]> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
const standings = await db.query.teamStandings.findMany({
|
|
|
|
|
where: eq(schema.teamStandings.seasonId, seasonId),
|
|
|
|
|
with: {
|
|
|
|
|
team: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
// Sort by currentRank ascending (1 is best, 2 is second, etc.)
|
2026-03-21 09:44:05 -07:00
|
|
|
const sorted = standings.toSorted((a, b) => a.currentRank - b.currentRank);
|
2025-11-13 13:24:03 -08:00
|
|
|
|
|
|
|
|
return sorted.map((standing) => ({
|
2025-10-28 23:50:50 -07:00
|
|
|
teamId: standing.teamId,
|
|
|
|
|
teamName: standing.team.name,
|
|
|
|
|
totalPoints: parseFloat(standing.totalPoints),
|
|
|
|
|
currentRank: standing.currentRank,
|
|
|
|
|
previousRank: standing.previousRank,
|
|
|
|
|
rankChange: standing.previousRank
|
|
|
|
|
? standing.previousRank - standing.currentRank
|
|
|
|
|
: 0,
|
|
|
|
|
placementCounts: {
|
|
|
|
|
first: standing.firstPlaceCount,
|
|
|
|
|
second: standing.secondPlaceCount,
|
|
|
|
|
third: standing.thirdPlaceCount,
|
|
|
|
|
fourth: standing.fourthPlaceCount,
|
|
|
|
|
fifth: standing.fifthPlaceCount,
|
|
|
|
|
sixth: standing.sixthPlaceCount,
|
|
|
|
|
seventh: standing.seventhPlaceCount,
|
|
|
|
|
eighth: standing.eighthPlaceCount,
|
|
|
|
|
},
|
|
|
|
|
participantsRemaining: standing.participantsRemaining,
|
|
|
|
|
calculatedAt: standing.calculatedAt,
|
2026-02-14 22:30:12 -08:00
|
|
|
// Phase 5.4: Include projected points
|
|
|
|
|
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
|
|
|
|
|
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
|
|
|
|
|
participantsFinished: standing.participantsFinished,
|
2025-10-28 23:50:50 -07:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get standings for a specific team
|
|
|
|
|
*/
|
|
|
|
|
export async function getTeamStanding(
|
|
|
|
|
teamId: string,
|
|
|
|
|
seasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<TeamStanding | null> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
const standing = await db.query.teamStandings.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.teamStandings.teamId, teamId),
|
|
|
|
|
eq(schema.teamStandings.seasonId, seasonId)
|
|
|
|
|
),
|
|
|
|
|
with: {
|
|
|
|
|
team: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!standing) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
teamId: standing.teamId,
|
|
|
|
|
teamName: standing.team.name,
|
|
|
|
|
totalPoints: parseFloat(standing.totalPoints),
|
|
|
|
|
currentRank: standing.currentRank,
|
|
|
|
|
previousRank: standing.previousRank,
|
|
|
|
|
rankChange: standing.previousRank
|
|
|
|
|
? standing.previousRank - standing.currentRank
|
|
|
|
|
: 0,
|
|
|
|
|
placementCounts: {
|
|
|
|
|
first: standing.firstPlaceCount,
|
|
|
|
|
second: standing.secondPlaceCount,
|
|
|
|
|
third: standing.thirdPlaceCount,
|
|
|
|
|
fourth: standing.fourthPlaceCount,
|
|
|
|
|
fifth: standing.fifthPlaceCount,
|
|
|
|
|
sixth: standing.sixthPlaceCount,
|
|
|
|
|
seventh: standing.seventhPlaceCount,
|
|
|
|
|
eighth: standing.eighthPlaceCount,
|
|
|
|
|
},
|
|
|
|
|
participantsRemaining: standing.participantsRemaining,
|
|
|
|
|
calculatedAt: standing.calculatedAt,
|
2026-02-14 22:30:12 -08:00
|
|
|
// Phase 5.4: Include projected points
|
|
|
|
|
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
|
|
|
|
|
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
|
|
|
|
|
participantsFinished: standing.participantsFinished,
|
2025-10-28 23:50:50 -07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get detailed team breakdown with all picks and their points
|
|
|
|
|
*/
|
|
|
|
|
export async function getTeamScoreBreakdown(
|
|
|
|
|
teamId: string,
|
|
|
|
|
seasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
) {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
// Get season scoring rules
|
|
|
|
|
const season = await db.query.seasons.findFirst({
|
|
|
|
|
where: eq(schema.seasons.id, seasonId),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!season) return null;
|
|
|
|
|
|
|
|
|
|
// Get all draft picks for this team with participant details
|
|
|
|
|
const picks = await db.query.draftPicks.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.draftPicks.teamId, teamId),
|
|
|
|
|
eq(schema.draftPicks.seasonId, seasonId)
|
|
|
|
|
),
|
|
|
|
|
orderBy: schema.draftPicks.pickNumber,
|
|
|
|
|
with: {
|
|
|
|
|
participant: {
|
|
|
|
|
with: {
|
|
|
|
|
sportsSeason: {
|
|
|
|
|
with: {
|
|
|
|
|
sport: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
results: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
// Get scoring rules for EV calculation
|
|
|
|
|
const scoringRules = {
|
|
|
|
|
pointsFor1st: season.pointsFor1st,
|
|
|
|
|
pointsFor2nd: season.pointsFor2nd,
|
|
|
|
|
pointsFor3rd: season.pointsFor3rd,
|
|
|
|
|
pointsFor4th: season.pointsFor4th,
|
|
|
|
|
pointsFor5th: season.pointsFor5th,
|
|
|
|
|
pointsFor6th: season.pointsFor6th,
|
|
|
|
|
pointsFor7th: season.pointsFor7th,
|
|
|
|
|
pointsFor8th: season.pointsFor8th,
|
|
|
|
|
};
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2026-03-17 14:00:32 -07:00
|
|
|
// Cache bracket template IDs per sports season (same approach as calculateTeamScore)
|
|
|
|
|
const bracketTemplateCache = new Map<string, string | null>();
|
|
|
|
|
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
|
|
|
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
2026-03-17 14:00:32 -07:00
|
|
|
}
|
|
|
|
|
const event = await db.query.scoringEvents.findFirst({
|
|
|
|
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
columns: { bracketTemplateId: true },
|
|
|
|
|
});
|
|
|
|
|
const templateId = event?.bracketTemplateId ?? null;
|
|
|
|
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
|
|
|
|
return templateId;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
// Calculate points and projected points for each pick
|
|
|
|
|
const pickBreakdown = await Promise.all(
|
|
|
|
|
picks.map(async (pick) => {
|
|
|
|
|
const result = pick.participant.results[0];
|
2026-03-17 14:00:32 -07:00
|
|
|
const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket";
|
2026-02-14 22:30:12 -08:00
|
|
|
let points = 0;
|
|
|
|
|
let projectedPoints: number | null = null;
|
|
|
|
|
|
2026-03-17 14:00:32 -07:00
|
|
|
const getEV = async () => {
|
2026-02-14 22:30:12 -08:00
|
|
|
const { getParticipantEV } = await import("./participant-expected-value");
|
|
|
|
|
const { calculateEV } = await import("~/services/ev-calculator");
|
2026-03-17 14:00:32 -07:00
|
|
|
const ev = await getParticipantEV(pick.participant.id, pick.participant.sportsSeasonId);
|
|
|
|
|
if (!ev) return null;
|
|
|
|
|
return calculateEV(
|
|
|
|
|
{
|
2026-02-14 22:30:12 -08:00
|
|
|
probFirst: parseFloat(ev.probFirst),
|
|
|
|
|
probSecond: parseFloat(ev.probSecond),
|
|
|
|
|
probThird: parseFloat(ev.probThird),
|
|
|
|
|
probFourth: parseFloat(ev.probFourth),
|
|
|
|
|
probFifth: parseFloat(ev.probFifth),
|
|
|
|
|
probSixth: parseFloat(ev.probSixth),
|
|
|
|
|
probSeventh: parseFloat(ev.probSeventh),
|
|
|
|
|
probEighth: parseFloat(ev.probEighth),
|
2026-03-17 14:00:32 -07:00
|
|
|
},
|
|
|
|
|
scoringRules
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-21 09:44:05 -07:00
|
|
|
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
2026-03-17 14:00:32 -07:00
|
|
|
// Calculate points using bracket-averaged scoring for bracket sports
|
|
|
|
|
if (isBracket) {
|
|
|
|
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
|
|
|
|
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
|
|
|
|
} else {
|
|
|
|
|
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
|
|
|
|
}
|
2026-02-14 22:30:12 -08:00
|
|
|
|
2026-03-17 14:00:32 -07:00
|
|
|
if (result.isPartialScore) {
|
|
|
|
|
// Still alive with a floor position — use EV for projected since they can advance
|
|
|
|
|
projectedPoints = (await getEV()) ?? points;
|
|
|
|
|
} else {
|
|
|
|
|
projectedPoints = points; // Finalized: projected equals actual
|
2026-02-14 22:30:12 -08:00
|
|
|
}
|
2026-03-17 14:00:32 -07:00
|
|
|
} else {
|
|
|
|
|
// Participant is unfinished - get EV
|
|
|
|
|
projectedPoints = await getEV();
|
2026-02-14 22:30:12 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
pickNumber: pick.pickNumber,
|
|
|
|
|
round: pick.round,
|
|
|
|
|
participant: {
|
|
|
|
|
id: pick.participant.id,
|
|
|
|
|
name: pick.participant.name,
|
|
|
|
|
sport: pick.participant.sportsSeason.sport.name,
|
|
|
|
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
|
|
|
|
},
|
|
|
|
|
finalPosition: result?.finalPosition ?? null,
|
|
|
|
|
points,
|
|
|
|
|
projectedPoints,
|
2026-03-17 14:00:32 -07:00
|
|
|
// isComplete: has a result record (even if partial/floor)
|
2026-02-14 22:30:12 -08:00
|
|
|
isComplete: !!result,
|
2026-03-17 14:00:32 -07:00
|
|
|
// isPartialScore: still alive with a provisional floor position
|
|
|
|
|
isPartialScore: result?.isPartialScore ?? false,
|
2026-02-14 22:30:12 -08:00
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
const actualPoints = pickBreakdown
|
|
|
|
|
.filter((p) => p.isComplete)
|
|
|
|
|
.reduce((sum, p) => sum + p.points, 0);
|
|
|
|
|
|
|
|
|
|
const projectedTotalPoints = pickBreakdown.reduce(
|
|
|
|
|
(sum, p) => sum + (p.projectedPoints ?? 0),
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
return {
|
|
|
|
|
team: await db.query.teams.findFirst({
|
|
|
|
|
where: eq(schema.teams.id, teamId),
|
|
|
|
|
}),
|
|
|
|
|
picks: pickBreakdown,
|
2026-02-14 22:30:12 -08:00
|
|
|
actualPoints,
|
|
|
|
|
projectedPoints: projectedTotalPoints,
|
2026-03-17 14:00:32 -07:00
|
|
|
completedCount: pickBreakdown.filter((p) => p.isComplete && !p.isPartialScore).length,
|
2025-10-28 23:50:50 -07:00
|
|
|
totalCount: pickBreakdown.length,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get historical standings snapshots for a team
|
|
|
|
|
* Used to display point progression over time
|
|
|
|
|
*/
|
|
|
|
|
export async function getTeamStandingsHistory(
|
|
|
|
|
teamId: string,
|
|
|
|
|
seasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<TeamStandingSnapshot[]> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
const snapshots = await db.query.teamStandingsSnapshots.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.teamStandingsSnapshots.teamId, teamId),
|
|
|
|
|
eq(schema.teamStandingsSnapshots.seasonId, seasonId)
|
|
|
|
|
),
|
|
|
|
|
orderBy: schema.teamStandingsSnapshots.snapshotDate,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return snapshots.map((snapshot) => ({
|
|
|
|
|
date: new Date(snapshot.snapshotDate),
|
|
|
|
|
rank: snapshot.rank,
|
|
|
|
|
totalPoints: parseFloat(snapshot.totalPoints),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get standings comparison between current and 7 days ago
|
|
|
|
|
* Used for "7-day change" display
|
|
|
|
|
*/
|
|
|
|
|
export async function getSevenDayStandingsChange(
|
|
|
|
|
seasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
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
|
|
|
): Promise<TeamStandingWithChange[]> {
|
2025-10-28 23:50:50 -07:00
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
const sevenDaysAgo = new Date();
|
|
|
|
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
|
|
|
|
|
|
|
|
// Get current standings
|
|
|
|
|
const current = await getSeasonStandings(seasonId, db);
|
|
|
|
|
|
|
|
|
|
// Get snapshots from 7 days ago
|
|
|
|
|
const snapshots = await db.query.teamStandingsSnapshots.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.teamStandingsSnapshots.seasonId, seasonId),
|
2026-03-18 22:15:28 -07:00
|
|
|
eq(schema.teamStandingsSnapshots.snapshotDate, `${sevenDaysAgo.getFullYear()}-${String(sevenDaysAgo.getMonth() + 1).padStart(2, "0")}-${String(sevenDaysAgo.getDate()).padStart(2, "0")}`)
|
2025-10-28 23:50:50 -07:00
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create a map of team -> old rank
|
|
|
|
|
const oldRanks = new Map<string, number>();
|
|
|
|
|
for (const snapshot of snapshots) {
|
|
|
|
|
oldRanks.set(snapshot.teamId, snapshot.rank);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add 7-day changes to current standings
|
|
|
|
|
return current.map((standing) => ({
|
|
|
|
|
...standing,
|
|
|
|
|
sevenDayRankChange: oldRanks.has(standing.teamId)
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank
|
2025-10-28 23:50:50 -07:00
|
|
|
: 0,
|
|
|
|
|
sevenDayOldRank: oldRanks.get(standing.teamId) || null,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a daily standings snapshot
|
|
|
|
|
* Should be called by a scheduled job once per day
|
|
|
|
|
*/
|
|
|
|
|
export async function createDailySnapshot(
|
|
|
|
|
seasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
2026-03-18 22:15:28 -07:00
|
|
|
const now = new Date();
|
|
|
|
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
|
|
|
const standings = await db.query.teamStandings.findMany({
|
|
|
|
|
where: eq(schema.teamStandings.seasonId, seasonId),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 22:15:28 -07:00
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
for (const standing of standings) {
|
|
|
|
|
const snapshotData = {
|
2025-10-28 23:50:50 -07:00
|
|
|
totalPoints: standing.totalPoints,
|
|
|
|
|
rank: standing.currentRank,
|
|
|
|
|
firstPlaceCount: standing.firstPlaceCount,
|
|
|
|
|
secondPlaceCount: standing.secondPlaceCount,
|
|
|
|
|
thirdPlaceCount: standing.thirdPlaceCount,
|
|
|
|
|
fourthPlaceCount: standing.fourthPlaceCount,
|
|
|
|
|
fifthPlaceCount: standing.fifthPlaceCount,
|
|
|
|
|
sixthPlaceCount: standing.sixthPlaceCount,
|
|
|
|
|
seventhPlaceCount: standing.seventhPlaceCount,
|
|
|
|
|
eighthPlaceCount: standing.eighthPlaceCount,
|
|
|
|
|
participantsRemaining: standing.participantsRemaining,
|
2026-02-14 22:30:12 -08:00
|
|
|
actualPoints: standing.actualPoints,
|
|
|
|
|
projectedPoints: standing.projectedPoints,
|
|
|
|
|
participantsFinished: standing.participantsFinished,
|
2026-03-18 22:15:28 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await tx
|
|
|
|
|
.insert(schema.teamStandingsSnapshots)
|
|
|
|
|
.values({ teamId: standing.teamId, seasonId, snapshotDate: today, ...snapshotData })
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
target: [
|
|
|
|
|
schema.teamStandingsSnapshots.teamId,
|
|
|
|
|
schema.teamStandingsSnapshots.seasonId,
|
|
|
|
|
schema.teamStandingsSnapshots.snapshotDate,
|
|
|
|
|
],
|
|
|
|
|
set: snapshotData,
|
|
|
|
|
});
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
2026-03-18 22:15:28 -07:00
|
|
|
});
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
logger.log(`[Standings] Upserted daily snapshot for season ${seasonId}`);
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
2025-11-14 21:18:34 -08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get point progression data for all teams in a season
|
|
|
|
|
* Returns historical snapshots organized by team for charting
|
|
|
|
|
*/
|
|
|
|
|
export async function getSeasonPointProgression(
|
|
|
|
|
seasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
) {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
// Get all snapshots for this season
|
|
|
|
|
const snapshots = await db.query.teamStandingsSnapshots.findMany({
|
|
|
|
|
where: eq(schema.teamStandingsSnapshots.seasonId, seasonId),
|
|
|
|
|
orderBy: schema.teamStandingsSnapshots.snapshotDate,
|
|
|
|
|
with: {
|
|
|
|
|
team: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Get unique teams
|
|
|
|
|
const teams = await db.query.teams.findMany({
|
|
|
|
|
where: eq(schema.teams.seasonId, seasonId),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Organize data by date
|
2026-03-21 09:44:05 -07:00
|
|
|
const dateMap = new Map<string, { date: string; [teamName: string]: string | number }>();
|
2025-11-14 21:18:34 -08:00
|
|
|
|
|
|
|
|
for (const snapshot of snapshots) {
|
|
|
|
|
const date = snapshot.snapshotDate;
|
|
|
|
|
|
|
|
|
|
if (!dateMap.has(date)) {
|
|
|
|
|
dateMap.set(date, { date });
|
|
|
|
|
}
|
|
|
|
|
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
const dateData = dateMap.get(date);
|
|
|
|
|
if (!dateData) continue;
|
2025-11-14 21:18:34 -08:00
|
|
|
dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert to array and sort by date
|
2026-03-21 09:44:05 -07:00
|
|
|
const chartData = Array.from(dateMap.values()).toSorted((a, b) =>
|
2025-11-14 21:18:34 -08:00
|
|
|
new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
chartData,
|
|
|
|
|
teams: teams.map(t => ({ id: t.id, name: t.name })),
|
|
|
|
|
};
|
|
|
|
|
}
|