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";
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
import { calculatePickPoints } from "~/models/scoring-rules";
|
|
|
|
|
import {
|
|
|
|
|
getSharedPlacementCounts,
|
|
|
|
|
lookupSharedPlacementCount,
|
|
|
|
|
} from "~/models/participant-result";
|
2026-03-21 13:41:39 -07:00
|
|
|
import { logger } from "~/lib/logger";
|
2026-04-23 22:09:25 -07:00
|
|
|
import { getParticipantEV } from "./participant-expected-value";
|
|
|
|
|
import { calculateEV } from "~/services/ev-calculator";
|
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
|
|
|
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
// Tie counts for qualifying_points picks, so a golfer tied for 8th is worth the
|
|
|
|
|
// split award here exactly as it is in calculateTeamScore. Counted across every
|
|
|
|
|
// participant in the sports season — an undrafted tie partner still halves it.
|
|
|
|
|
const sharedPlacementCounts = await getSharedPlacementCounts(
|
|
|
|
|
[...new Set(picks.map((p) => p.participant.sportsSeasonId))],
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
|
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];
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
const pattern = pick.participant.sportsSeason.scoringPattern;
|
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 () => {
|
|
|
|
|
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) {
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
|
|
|
|
|
bracketTemplateId:
|
|
|
|
|
pattern === "playoff_bracket"
|
|
|
|
|
? await getBracketTemplate(pick.participant.sportsSeasonId)
|
|
|
|
|
: null,
|
|
|
|
|
tiedParticipants: lookupSharedPlacementCount(
|
|
|
|
|
sharedPlacementCounts,
|
|
|
|
|
pick.participant.sportsSeasonId,
|
|
|
|
|
result.finalPosition
|
|
|
|
|
),
|
|
|
|
|
});
|
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
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-22 11:05:13 -07:00
|
|
|
// Create a map of team -> old rank and old points
|
2025-10-28 23:50:50 -07:00
|
|
|
const oldRanks = new Map<string, number>();
|
2026-03-22 11:05:13 -07:00
|
|
|
const oldPoints = new Map<string, number>();
|
2025-10-28 23:50:50 -07:00
|
|
|
for (const snapshot of snapshots) {
|
|
|
|
|
oldRanks.set(snapshot.teamId, snapshot.rank);
|
2026-03-22 11:05:13 -07:00
|
|
|
const snapshotPoints = snapshot.actualPoints
|
|
|
|
|
? parseFloat(snapshot.actualPoints)
|
|
|
|
|
: parseFloat(snapshot.totalPoints);
|
|
|
|
|
oldPoints.set(snapshot.teamId, snapshotPoints);
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add 7-day changes to current standings
|
2026-03-22 11:05:13 -07:00
|
|
|
return current.map((standing) => {
|
|
|
|
|
const currentPoints = standing.actualPoints ?? standing.totalPoints;
|
|
|
|
|
const oldPoint = oldPoints.get(standing.teamId);
|
|
|
|
|
return {
|
|
|
|
|
...standing,
|
|
|
|
|
sevenDayRankChange: oldRanks.has(standing.teamId)
|
|
|
|
|
? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank
|
|
|
|
|
: 0,
|
|
|
|
|
sevenDayOldRank: oldRanks.get(standing.teamId) || null,
|
|
|
|
|
sevenDayPointChange: oldPoint !== undefined ? currentPoints - oldPoint : 0,
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 })),
|
|
|
|
|
};
|
|
|
|
|
}
|