From 7816ef55340aa798443c71734b4b6eba0615ce3f Mon Sep 17 00:00:00 2001 From: willg-sys Date: Tue, 30 Jun 2026 16:37:44 -0400 Subject: [PATCH 1/2] Add HRA Masters Points Series calculator and visualizer --- README.md | 2 +- code | 0 src/calculators/HRAPointsCalc.ts | 220 +++++++++++++++++++++++++++++++ src/components/HRAPoints.tsx | 108 +++++++++++++++ src/index.ts | 6 + tests/HRAPointsCalc.test.ts | 135 +++++++++++++++++++ yarn.lock | 17 +-- 7 files changed, 471 insertions(+), 17 deletions(-) create mode 100644 code create mode 100644 src/calculators/HRAPointsCalc.ts create mode 100644 src/components/HRAPoints.tsx create mode 100644 tests/HRAPointsCalc.test.ts diff --git a/README.md b/README.md index e3a0c63..3370fa6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/code b/code new file mode 100644 index 0000000..e69de29 diff --git a/src/calculators/HRAPointsCalc.ts b/src/calculators/HRAPointsCalc.ts new file mode 100644 index 0000000..701de06 --- /dev/null +++ b/src/calculators/HRAPointsCalc.ts @@ -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 = { + '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 => { + const teamPoints = new Map(); + + 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(); + + 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 => { + const teamTotals = new Map(); + + 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, 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'), + }; +}; diff --git a/src/components/HRAPoints.tsx b/src/components/HRAPoints.tsx new file mode 100644 index 0000000..1a2dfcf --- /dev/null +++ b/src/components/HRAPoints.tsx @@ -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( + + {/* Overall */} + {points.overall[i]?.place ?? ''} + {points.overall[i]?.team ?? ''} + {points.overall[i]?.points.toFixed(1) ?? ''} + + {/* Sweep */} + {points.sweep[i]?.place ?? ''} + {points.sweep[i]?.team ?? ''} + {points.sweep[i]?.points.toFixed(1) ?? ''} + + {/* Sculling */} + {points.sculling[i]?.place ?? ''} + {points.sculling[i]?.team ?? ''} + {points.sculling[i]?.points.toFixed(1) ?? ''} + , + ); + } + + return ( + + HRA Masters Points Series + + + + + Overall + + + Sweep + + + Sculling + + + + {/* Overall header row */} + + Place + + Team + Pts + + {/* Sweep header row */} + + Place + + Team + Pts + + {/* Sculling header row */} + + Place + + Team + Pts + + + {rows} +
+ + Composite crews are ineligible to earn points. Points awarded to all non-composite finishers regardless of entry + count. Only A finals count. + +
+ ); +}; diff --git a/src/index.ts b/src/index.ts index 47f7fa5..9b8987d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -84,4 +85,9 @@ export const PointsViewers: PointsViewerInfo[] = [ key: 'StarsAndStripesPoints', ui: StarsAndStripesPoints, }, + { + name: 'HRA Masters Points Series', + key: 'HRAPoints', + ui: HRAPoints, + }, ]; diff --git a/tests/HRAPointsCalc.test.ts b/tests/HRAPointsCalc.test.ts new file mode 100644 index 0000000..341df12 --- /dev/null +++ b/tests/HRAPointsCalc.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for the HRA Masters Points Series calculator. + * + * Run with: yarn test + */ + +import { basePointsForEvent, calcEventPoints, hraPointsCalc } from '../src/calculators/HRAPointsCalc'; + +// ─── Unit: basePointsForEvent ───────────────────────────────────────────────── + +describe('basePointsForEvent', () => { + test('Men 8+ → 30', () => expect(basePointsForEvent('Men 8+')).toBe(30)); + test('Women 8+ → 30', () => expect(basePointsForEvent('Women 8+')).toBe(30)); + test('Men 4+ → 24', () => expect(basePointsForEvent('Men 4+')).toBe(24)); + test('Women 4x → 24', () => expect(basePointsForEvent('Women 4x')).toBe(24)); + test('Mixed 8+ → 18', () => expect(basePointsForEvent('Mixed 8+')).toBe(18)); + test('Mixed 4x → 18', () => expect(basePointsForEvent('Mixed 4x')).toBe(18)); + test('Mixed 2x → 18', () => expect(basePointsForEvent('Mixed 2x')).toBe(18)); + test('Men 2x → 12', () => expect(basePointsForEvent('Men 2x')).toBe(12)); + test('Women 2- → 12', () => expect(basePointsForEvent('Women 2-')).toBe(12)); + test('Men 1x → 9', () => expect(basePointsForEvent('Men 1x')).toBe(9)); + test('Women 1x → 9', () => expect(basePointsForEvent('Women 1x')).toBe(9)); +}); + +// ─── Unit: calcEventPoints ──────────────────────────────────────────────────── + +const makeEvent = (name: string, eventNum: string, crews: { name: string; place: number | null }[]) => ({ + Event: name, + EventNum: eventNum, + entries: crews.map((c) => ({ + Crew: c.name, + Place: c.place ?? undefined, + PenaltyCode: '', + })), +}); + +describe('calcEventPoints – Women 8+ A Final', () => { + const event = makeEvent('Women 8+ Masters A Final', '1A', [ + { name: 'Tampa Bay Rowing', place: 1 }, + { name: 'Sarasota Crew', place: 2 }, + { name: 'Jacksonville Rowing', place: 3 }, + ]); + + const result = calcEventPoints(event as any); + + test('1st place earns 30 pts (100%)', () => expect(result.get('Tampa Bay Rowing')).toBeCloseTo(30.0)); + test('2nd place earns 24 pts (80%)', () => expect(result.get('Sarasota Crew')).toBeCloseTo(24.0)); + test('3rd place earns 18 pts (60%)', () => expect(result.get('Jacksonville Rowing')).toBeCloseTo(18.0)); +}); + +describe('calcEventPoints – Mixed 4x A Final', () => { + const event = makeEvent('Mixed 4x Masters A Final', '2A', [ + { name: 'Orlando Masters', place: 1 }, + { name: 'Gainesville Rowing', place: 2 }, + ]); + + const result = calcEventPoints(event as any); + + test('Mixed event 1st place earns 18 pts', () => expect(result.get('Orlando Masters')).toBeCloseTo(18.0)); + test('Mixed event 2nd place earns 14.4 pts (80%)', () => expect(result.get('Gainesville Rowing')).toBeCloseTo(14.4)); +}); + +describe('calcEventPoints – composite crew exclusion', () => { + const event = makeEvent('Men 4+ Masters A Final', '3A', [ + { name: 'Tampa/Sarasota', place: 1 }, // composite — slash in name + { name: 'Orlando Masters', place: 2 }, + ]); + + const result = calcEventPoints(event as any); + + test('composite crew earns no points', () => expect(result.has('Tampa/Sarasota')).toBe(false)); + test('2nd-place non-composite crew earns 2nd-place points', () => + expect(result.get('Orlando Masters')).toBeCloseTo(19.2)); +}); + +describe('calcEventPoints – heat is excluded', () => { + const event = makeEvent('Women 1x Masters Heat 1', '5H1', [{ name: 'Some Crew', place: 1 }]); + const result = calcEventPoints(event as any); + test('heat returns empty map', () => expect(result.size).toBe(0)); +}); + +describe('calcEventPoints – only first entry per team counts', () => { + const event = makeEvent('Men 2x Masters A Final', '4A', [ + { name: 'Tampa Bay Rowing A', place: 1 }, + { name: 'Tampa Bay Rowing B', place: 2 }, // same team, B boat + { name: 'Sarasota Crew', place: 3 }, + ]); + const result = calcEventPoints(event as any); + // "Tampa Bay Rowing A" and "Tampa Bay Rowing B" both strip to "Tampa Bay Rowing" + test('B-boat does not give team double credit', () => expect(result.get('Tampa Bay Rowing')).toBeCloseTo(12.0)); + test('3rd place crew gets 3rd-place points (effectively 2nd unique team)', () => + expect(result.get('Sarasota Crew')).toBeCloseTo(7.2)); +}); + +// ─── Integration: hraPointsCalc ───────────────────────────────────────────── + +describe('hraPointsCalc – integration', () => { + const mockResults: any = { + results: [ + makeEvent('Men 8+ Masters A Final', '1A', [ + { name: 'Tampa Bay Rowing', place: 1 }, // 30 pts sweep + { name: 'Sarasota Crew', place: 2 }, // 24 pts sweep + ]), + makeEvent('Men 1x Masters A Final', '2A', [ + { name: 'Tampa Bay Rowing', place: 1 }, // 9 pts sculling + { name: 'Gainesville Rowing', place: 2 }, // 7.2 pts sculling + ]), + ] as any, + }; + + const points = hraPointsCalc(mockResults); + + test('Tampa Bay Rowing overall = 39 (30 + 9)', () => { + const entry = points.overall.find((r) => r.team === 'Tampa Bay Rowing'); + expect(entry?.points).toBeCloseTo(39.0); + }); + + test('overall standings are sorted by points descending', () => { + expect(points.overall[0].points).toBeGreaterThanOrEqual(points.overall[1]?.points ?? 0); + }); + + test('Tampa Bay Rowing sweep = 30', () => { + const entry = points.sweep.find((r) => r.team === 'Tampa Bay Rowing'); + expect(entry?.points).toBeCloseTo(30.0); + }); + + test('Tampa Bay Rowing sculling = 9', () => { + const entry = points.sculling.find((r) => r.team === 'Tampa Bay Rowing'); + expect(entry?.points).toBeCloseTo(9.0); + }); + + test('places are assigned correctly (1st, 2nd, ...)', () => { + expect(points.overall[0].place).toBe(1); + }); +}); diff --git a/yarn.lock b/yarn.lock index 66707ec..4537165 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2774,26 +2774,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" -crewtimer-common@^1.0.19, crewtimer-common@^1.0.24: +crewtimer-common@^1.0.24: version "1.0.24" resolved "https://registry.yarnpkg.com/crewtimer-common/-/crewtimer-common-1.0.24.tgz#6cf5b65f01ca22fc961d9b3cf004369850b949b3" integrity sha512-KoC3lAFLmel3WJggFZdNIlOAkB9p3qtaYmPDP7KbibEFyu8i5bB1EcpSbfDsOB7/Zh3LfHORZxXPM96HIu17fQ== -crewtimer-points@^1.0.27: - version "1.0.27" - resolved "https://registry.yarnpkg.com/crewtimer-points/-/crewtimer-points-1.0.27.tgz#1ac53a6f0b8afdb14259fbd5a300329d5c75a928" - integrity sha512-J0/UKfCTvxCXC83eBwUejzmCKhIqW6G50wOovXG3RIbMu3oTZY1yNX2q7393hJz7J+bg858UFyFhXvtyBm/+sw== - dependencies: - "@emotion/react" "^11.10.6" - "@emotion/styled" "^11.10.6" - "@mui/lab" "^5.0.0-alpha.124" - "@mui/material" "^5.11.13" - "@mui/styles" "^5.11.13" - crewtimer-common "^1.0.19" - react "^18.2.0" - react-dom "^18.2.0" - react-usedatum "^1.0.7" - cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" From d20f05cd95cd82f385a2d0d27b3faabf9416c4c8 Mon Sep 17 00:00:00 2001 From: willg-sys Date: Tue, 30 Jun 2026 16:38:51 -0400 Subject: [PATCH 2/2] Remove stray file --- code | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 code diff --git a/code b/code deleted file mode 100644 index e69de29..0000000