Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The visualizers utilize the [React framework](https://react.dev/) along with the
* [Hebda Cup](docs/HebdaScoring.pdf) This system awards points to the top 3 finishers of each final of four or more entries, top 2 for three entries, and first place only for a two-boat final. Full points are awarded for each boat class, regardless of event level.
* [Wy-Hi Regatta](docs/WyHiScoring.pdf) This points system uses a modified version of the Barnes System and awards scaled points based on if races are finals only or if heats were necessary. Full points are awarded for each boat class, regardless of event level.
* [Chicago Sprints](docs/SprintsPoints.pdf) This points system uses a modified version of the Barnes System. The maximum points for an event is determined by the boat class and subsequent points are scaled based on the number of entries in the event.

* [HRA Masters Points Series] (https://www.floridarowing.org/fmra/clubpnts.html) A modified US Rowing Points System for Florida masters regattas. Points are awarded to all non-composite finishers across sweep and sculling events, with separate overall, sweep, and sculling standings.
## Adding a new points engine

### Prerequisites
Expand Down
220 changes: 220 additions & 0 deletions src/calculators/HRAPointsCalc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { Event, Results, genPlaces } from 'crewtimer-common';
import { isAFinal, boatClassFromName } from '../common/CrewTimerUtils';
// ─── Types ────────────────────────────────────────────────────────────────────

export type HRATeamTotals = {
overall: number;
sweep: number;
sculling: number;
};

export type HRAResult = {
team: string;
points: number;
place: number;
};

export type HRAPoints = {
overall: HRAResult[];
sweep: HRAResult[];
sculling: HRAResult[];
};

// ─── Points Table ─────────────────────────────────────────────────────────────

/**
* Base (1st place / 100%) points by boat class, as defined by HRA rules:
*
* M/W 8+ → 30
* M/W 4+, 4x → 24
* Mixed 8+,4+,4x,2x→ 18
* M/W 2x, 2- → 12
* Singles (1x) → 9
*/
const BASE_POINTS_BY_CLASS: Record<string, number> = {
'8': 30, // Men / Women sweep eight (boatClassFromName strips the '+')
'4': 24, // Men / Women coxed/coxless four
'4x': 24, // Men / Women quad
'2x': 12, // Men / Women double
'2': 12, // Men / Women pair (boatClassFromName strips the '-')
'1x': 9, // Singles
};

/**
* Place-based percentages per HRA rules (Section 4 points table).
* Index 0 = 1st place (100%), index 5 = 6th place (15%).
*/
const PLACE_PERCENTAGES = [1.0, 0.8, 0.6, 0.45, 0.3, 0.15];

// ─── Helpers ──────────────────────────────────────────────────────────────────

const COMPOSITE_MARKERS = ['/']; // composite crews use "/" in crew name per common convention

/**
* Returns true when a crew name indicates a composite crew (rowers from 2+ teams).
* Composite crews are ineligible to earn points (Section 5).
*/
const isComposite = (crewName: string): boolean => COMPOSITE_MARKERS.some((marker) => crewName.includes(marker));

/**
* Strip trailing single-char suffixes like "A" / "B" boat designators and
* seeding brackets like "[1]" so B-boats don't create phantom team entries.
*/
const normalizeTeamName = (crewName: string): string => {
// Remove trailing " [n]" seeding annotations
crewName = crewName.replace(/\s+\[?.?\]?$/, '');
// Remove trailing single-character boat suffix (A/B designators)
crewName = crewName.replace(/ .$/, '');
return crewName.trim();
};

/**
* Determine whether this is a mixed event.
* HRA treats Mixed 8+, 4+, 4x, 2x as a separate points column (18 base pts).
*/
const isMixedEvent = (eventName: string): boolean => {
const upper = eventName.toUpperCase();
return upper.includes('MIXED') || upper.includes('MIX');
};

/**
* Returns true for sculling events (4x, 2x, 1x).
* Everything else (8+, 4+, 4-, 2-) is sweep.
*/
const isScullingEvent = (eventName: string): boolean => {
const boat = boatClassFromName(eventName);
return ['4x', '2x', '1x'].includes(boat ?? '');
};

/**
* Look up the base (100%) points for an event based on boat class and gender.
* Mixed events use their own column regardless of boat class.
*/
export const basePointsForEvent = (eventName: string): number => {
if (isMixedEvent(eventName)) {
return 18; // Mixed column covers 8+, 4+, 4x, 2x
}
const boat = boatClassFromName(eventName);
return BASE_POINTS_BY_CLASS[boat ?? ''] ?? 0;
};

/**
* Given a finishing place (1-based), return the points multiplier.
* Places beyond 6th earn 0 points.
*/
const multiplierForPlace = (place: number): number =>
place >= 1 && place <= PLACE_PERCENTAGES.length ? PLACE_PERCENTAGES[place - 1] : 0;

// ─── Core calculation ─────────────────────────────────────────────────────────

/**
* Calculate per-team points for a single event final.
* Returns a map of { teamName → points }.
*
* Rules applied:
* - Only A finals count (Section 4 requires completed races; heats/TTs excluded)
* - Composite crews earn no points (Section 5)
* - All non-composite boats earn points regardless of entry count (Section 6)
* - Only the first (highest-placing) entry per team per event counts
*/
export const calcEventPoints = (event: Event): Map<string, number> => {
const teamPoints = new Map<string, number>();

if (!isAFinal(event.Event, event.EventNum)) {
return teamPoints;
}

const base = basePointsForEvent(event.Event);
if (base === 0) {
return teamPoints; // unrecognised boat class — skip
}

const seenTeams = new Set<string>();

const sortedEntries = [...(event.entries ?? [])].sort(
(a, b) => (a.Place ?? Number.MAX_VALUE) - (b.Place ?? Number.MAX_VALUE),
);

for (const entry of sortedEntries) {
if (!entry.Place) continue; // DNF / DNS / DQ / no result
if (isComposite(entry.Crew)) continue; // composite — ineligible (Section 5)

const team = normalizeTeamName(entry.Crew);
if (seenTeams.has(team)) continue; // only first entry per team counts
seenTeams.add(team);

const pts = base * multiplierForPlace(entry.Place);
if (pts > 0) {
teamPoints.set(team, pts);
}
}

return teamPoints;
};

// ─── Aggregation ──────────────────────────────────────────────────────────────

/**
* Core HRA points implementation.
* Iterates all events, accumulates team totals split by sweep / sculling.
*/
export const hraPointsImpl = (resultData: Results): Map<string, HRATeamTotals> => {
const teamTotals = new Map<string, HRATeamTotals>();

const addPoints = (team: string, pts: number, sculling: boolean) => {
const existing = teamTotals.get(team) ?? { overall: 0, sweep: 0, sculling: 0 };
teamTotals.set(team, {
overall: existing.overall + pts,
sweep: existing.sweep + (sculling ? 0 : pts),
sculling: existing.sculling + (sculling ? pts : 0),
});
};

for (const event of resultData.results ?? []) {
const sculling = isScullingEvent(event.Event);
const eventPoints = calcEventPoints(event);
eventPoints.forEach((pts, team) => addPoints(team, pts, sculling));
}

// Ensure every Florida team that entered (even with 0 pts) appears in results
for (const event of resultData.results ?? []) {
for (const entry of event.entries ?? []) {
if (isComposite(entry.Crew)) continue;
const team = normalizeTeamName(entry.Crew);
if (!teamTotals.has(team)) {
teamTotals.set(team, { overall: 0, sweep: 0, sculling: 0 });
}
}
}

return teamTotals;
};

// ─── Finalise & rank ──────────────────────────────────────────────────────────

const rankResults = (entries: Map<string, HRATeamTotals>, key: keyof HRATeamTotals): HRAResult[] => {
const arr = Array.from(entries.entries())
.map(([team, totals]) => ({ team, points: totals[key], place: 0 }))
.sort((a, b) => b.points - a.points)
.filter((r) => r.points > 0);

const places = genPlaces(
arr.map((r) => r.points),
'desc',
);
places.forEach((place, i) => (arr[i].place = place));
return arr;
};

/**
* Public entry point.
* Returns ranked overall, sweep, and sculling standings.
*/
export const hraPointsCalc = (resultData: Results): HRAPoints => {
const totals = hraPointsImpl(resultData);
return {
overall: rankResults(totals, 'overall'),
sweep: rankResults(totals, 'sweep'),
sculling: rankResults(totals, 'sculling'),
};
};
108 changes: 108 additions & 0 deletions src/components/HRAPoints.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Table, TableHead, TableRow, TableCell, TableBody, Stack, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { Results } from 'crewtimer-common';
import React from 'react';
import { hraPointsCalc } from '../calculators/HRAPointsCalc';

const StyledTableRow = styled(TableRow)(({ theme }) => ({
'&:nth-of-type(odd)': {
backgroundColor: theme.palette.action.hover,
},
'&:last-child th': {
border: 0,
},
}));

const HeaderTableCell = styled(TableCell)(() => ({
fontWeight: 'bold',
}));

const DividerCell = styled(TableCell)(() => ({
borderLeft: '1px solid #808080',
}));

/**
* HRA Points Visualizer
*
* Displays three side-by-side rankings:
* • Overall (combined sweep + sculling)
* • Sweep events only
* • Sculling events only
*
* Based on HRA Rules for Point Series Regattas (modified US Rowing Points System).
*/
export const HRAPoints: React.FC<{ results: Results }> = ({ results }) => {
const points = hraPointsCalc(results);

const maxRows = Math.max(points.overall.length, points.sweep.length, points.sculling.length);

const rows: JSX.Element[] = [];
for (let i = 0; i < maxRows; i++) {
rows.push(
<StyledTableRow key={i}>
{/* Overall */}
<DividerCell align='right'>{points.overall[i]?.place ?? ''}</DividerCell>
<TableCell>{points.overall[i]?.team ?? ''}</TableCell>
<TableCell align='right'>{points.overall[i]?.points.toFixed(1) ?? ''}</TableCell>

{/* Sweep */}
<DividerCell align='right'>{points.sweep[i]?.place ?? ''}</DividerCell>
<TableCell>{points.sweep[i]?.team ?? ''}</TableCell>
<TableCell align='right'>{points.sweep[i]?.points.toFixed(1) ?? ''}</TableCell>

{/* Sculling */}
<DividerCell align='right'>{points.sculling[i]?.place ?? ''}</DividerCell>
<TableCell>{points.sculling[i]?.team ?? ''}</TableCell>
<TableCell align='right'>{points.sculling[i]?.points.toFixed(1) ?? ''}</TableCell>
</StyledTableRow>,
);
}

return (
<Stack alignItems='center' spacing={1}>
<Typography variant='h6'>HRA Masters Points Series</Typography>
<Table size='small' sx={{ width: 'auto' }}>
<TableHead>
<TableRow>
<HeaderTableCell align='center' colSpan={3} sx={{ borderLeft: '1px solid #808080' }}>
Overall
</HeaderTableCell>
<HeaderTableCell align='center' colSpan={3} sx={{ borderLeft: '1px solid #808080' }}>
Sweep
</HeaderTableCell>
<HeaderTableCell align='center' colSpan={3} sx={{ borderLeft: '1px solid #808080' }}>
Sculling
</HeaderTableCell>
</TableRow>
<TableRow>
{/* Overall header row */}
<DividerCell>
<b>Place</b>
</DividerCell>
<HeaderTableCell>Team</HeaderTableCell>
<HeaderTableCell align='right'>Pts</HeaderTableCell>

{/* Sweep header row */}
<DividerCell>
<b>Place</b>
</DividerCell>
<HeaderTableCell>Team</HeaderTableCell>
<HeaderTableCell align='right'>Pts</HeaderTableCell>

{/* Sculling header row */}
<DividerCell>
<b>Place</b>
</DividerCell>
<HeaderTableCell>Team</HeaderTableCell>
<HeaderTableCell align='right'>Pts</HeaderTableCell>
</TableRow>
</TableHead>
<TableBody>{rows}</TableBody>
</Table>
<Typography variant='caption' color='text.secondary'>
Composite crews are ineligible to earn points. Points awarded to all non-composite finishers regardless of entry
count. Only A finals count.
</Typography>
</Stack>
);
};
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FIRAPointsTraditional } from './components/FIRAPoints';
import { HebdaPoints, WyHiPoints } from './components/WyandottePoints';
import { SprintsPointsTraditional } from './components/SprintsPoints';
import { StarsAndStripesPoints } from './components/StarsAndStripesPoints';
import { HRAPoints } from './components/HRAPoints';

export interface PointsViewerInfo {
name: string; /// User presentable string
Expand Down Expand Up @@ -84,4 +85,9 @@ export const PointsViewers: PointsViewerInfo[] = [
key: 'StarsAndStripesPoints',
ui: StarsAndStripesPoints,
},
{
name: 'HRA Masters Points Series',
key: 'HRAPoints',
ui: HRAPoints,
},
];
Loading