swimparse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Goergen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # swimparse
2
+
3
+ Reads swim-meet result files — **SDIF v3** (`.sd3`) and **Hy-Tek** (`.hy3`) — and emits
4
+ one `NormalizedMeet` JSON shape, so tools consume a single contract instead of
5
+ re-implementing fixed-width parsing twice.
6
+
7
+ - **Zero dependencies.** Plain ESM — browser, Node, and CI unchanged.
8
+ - **Lossless.** Every swim is kept: placing or not, exhibition, DQ, no-show — plus
9
+ birthdates, seed times, relay legs, splits and DQ reasons.
10
+ - **Format-agnostic output.** SDIF and HY3 of the same meet parse to the same result.
11
+
12
+ ## Scope
13
+
14
+ swimparse reads files and emits JSON. **That is all it does.** It has no concept of a
15
+ league: no age bands, no scoring rules, no team-code registry, no qualifying standards,
16
+ no synthesized meet names.
17
+
18
+ That boundary is deliberate. Those things differ per league and change over time, while
19
+ the file formats do not. Keeping them out means a summer-league scorer, a USA-Swimming
20
+ analyzer, and a championship-meet tool can share one parser and disagree about
21
+ everything else.
22
+
23
+ | Concern | Where it belongs |
24
+ |---|---|
25
+ | SDIF / HY3 record layouts, times, dates, format detection | **swimparse** |
26
+ | The `NormalizedMeet` contract | **swimparse** |
27
+ | Age bands, age-up date, age-group labels for swimmers | your league layer |
28
+ | Scoring: point values, which relays count, team totals | your league layer |
29
+ | Canonical team codes / alias mapping | your league layer |
30
+ | Stripping or aggregating PII | your league layer |
31
+ | Qualifying standards, records, personal bests | your application |
32
+
33
+ ## Install
34
+
35
+ Consumed as a pinned git dependency:
36
+
37
+ ```json
38
+ "dependencies": { "swimparse": "github:g0rgonus/swimparse#v0.1.0" }
39
+ ```
40
+
41
+ Pin to a tag or SHA for reproducibility. `npm ci` clones it in CI with no publish step.
42
+
43
+ ## Usage
44
+
45
+ ```js
46
+ import { parse, detectFormat } from 'swimparse';
47
+
48
+ const meet = parse(fileText, { filename: 'GG_at_WW.hy3' }); // auto-detects format
49
+ const meet = parse(fileText, { format: 'sdif-v3' }); // or force one
50
+ ```
51
+
52
+ CLI:
53
+
54
+ ```bash
55
+ swimparse meet.hy3 --pretty # NormalizedMeet JSON to stdout
56
+ swimparse meet.sd3 -o meet.json # to a file
57
+ swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input
58
+ ```
59
+
60
+ ## The NormalizedMeet contract
61
+
62
+ `{ format, source, meet, teams, swimmers, events }` — see [`src/model.js`](src/model.js)
63
+ for the full typedefs. Highlights:
64
+
65
+ - **Times** always carry both `{ text: "1:11.35", seconds: 71.35 }`.
66
+ - **Dates** are ISO `YYYY-MM-DD`.
67
+ - **`result.status`** is `ok | dq | ns | dnf | scratch | exhibition`.
68
+ - **`event.ageGroup`** is the *event's* age range as printed in the file (`"9-10"`,
69
+ `"10 & Under"`, `"Open"`). It is **not** a swimmer's age group — computing that needs
70
+ a birthdate and a league's bands, which is your layer's job.
71
+ - **`team.code`** has the two-letter LSC prefix stripped (`VAWW` → `WW`), an SDIF file
72
+ convention; `team.fullCode` keeps the raw value. Mapping either onto a league's
73
+ canonical code is your layer's job.
74
+ - **`result.points`** is whatever the file stored. SDIF carries points; HY3 does not, so
75
+ it reads `0`. Deriving points from place is scoring, so it lives in your layer.
76
+
77
+ ### Format differences worth knowing
78
+
79
+ | | SDIF (`.sd3`) | Hy-Tek (`.hy3`) |
80
+ |---|---|---|
81
+ | DQ time | nulled | **retained** (`finalTime` kept) |
82
+ | DQ reason | — | **`dqReason`** (e.g. "Arms: Underwater recovery") |
83
+ | Points | stored | absent (reads `0`) |
84
+ | Names | 28-char field | wider, less truncation |
85
+
86
+ ## Privacy
87
+
88
+ The lossless rule means output carries **`swimmers[].birthDate` and `usasId` whenever the
89
+ file does.** At a youth meet that is PII for minors, so **treat every parse result as
90
+ confidential** until your application has stripped or aggregated it.
91
+
92
+ swimparse does not sanitize for you, on purpose: what counts as safe is a league decision
93
+ (a summer league publishes age-group labels and drops birthdates; a USA-Swimming tool
94
+ needs exact ages). Putting that choice in the parser would force one answer on everyone.
95
+
96
+ Test fixtures in this repo are synthetic — every identity is a public figure with a
97
+ shifted birth year; see [`test/fixtures/README.md`](test/fixtures/README.md).
98
+
99
+ ## Tests
100
+
101
+ ```bash
102
+ node --test # golden snapshots + SDIF↔HY3 cross-agreement
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT
package/cli.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * swimparse CLI — turn .sd3/.hy3 files into NormalizedMeet JSON.
4
+ *
5
+ * swimparse meet.hy3 # JSON to stdout
6
+ * swimparse meet.sd3 -o meet.json # JSON to a file
7
+ * swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input, into out/
8
+ * swimparse meet.hy3 --pretty # 2-space indented
9
+ *
10
+ * PRIVACY: the output contains swimmer birthdates and registration ids exactly
11
+ * as the source file carries them. For a youth meet that is PII for minors —
12
+ * do not publish it or commit it to a public repo without sanitizing first.
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
+ import { basename, join, extname } from 'node:path';
17
+ import { parse } from './src/index.js';
18
+
19
+ function main(argv) {
20
+ const args = argv.slice(2);
21
+ const inputs = [];
22
+ let outFile = null;
23
+ let outDir = null;
24
+ let pretty = false;
25
+
26
+ for (let i = 0; i < args.length; i++) {
27
+ const a = args[i];
28
+ if (a === '-o' || a === '--out') outFile = args[++i];
29
+ else if (a === '-d' || a === '--out-dir') outDir = args[++i];
30
+ else if (a === '--pretty') pretty = true;
31
+ else if (a === '-h' || a === '--help') return help(0);
32
+ else if (a.startsWith('-')) return fail(`unknown option: ${a}`);
33
+ else inputs.push(a);
34
+ }
35
+ if (inputs.length === 0) return help(1);
36
+ if (outFile && inputs.length > 1) return fail('-o takes a single input; use -d for multiple');
37
+
38
+ const indent = pretty ? 2 : 0;
39
+ if (outDir) mkdirSync(outDir, { recursive: true });
40
+
41
+ for (const file of inputs) {
42
+ const meet = parse(readFileSync(file, 'latin1'), { filename: file });
43
+ const json = JSON.stringify(meet, null, indent);
44
+ if (outDir) {
45
+ const name = basename(file, extname(file)) + '.json';
46
+ writeFileSync(join(outDir, name), json);
47
+ process.stderr.write(`wrote ${join(outDir, name)} (${meet.format}, ${meet.events.length} events)\n`);
48
+ } else if (outFile) {
49
+ writeFileSync(outFile, json);
50
+ process.stderr.write(`wrote ${outFile} (${meet.format}, ${meet.events.length} events)\n`);
51
+ } else {
52
+ process.stdout.write(json + '\n');
53
+ }
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ function help(codeNum) {
59
+ process.stdout.write(
60
+ 'Usage: swimparse <file...> [-o out.json | -d out-dir] [--pretty]\n' +
61
+ ' Parses SDIF (.sd3) or Hy-Tek (.hy3) results into NormalizedMeet JSON.\n' +
62
+ ' -o, --out <path> write a single input to this file\n' +
63
+ ' -d, --out-dir <dir> write one <name>.json per input into this directory\n' +
64
+ ' --pretty 2-space indented JSON\n' +
65
+ '\n' +
66
+ ' Output carries swimmer birthdates as they appear in the file. Sanitize\n' +
67
+ ' before publishing. Age banding, scoring, and team-code mapping are league\n' +
68
+ ' policy and are not performed here.\n'
69
+ );
70
+ return codeNum;
71
+ }
72
+ function fail(msg) {
73
+ process.stderr.write(`swimparse: ${msg}\n`);
74
+ return 2;
75
+ }
76
+
77
+ // Set exitCode rather than process.exit(): process.exit() can terminate before
78
+ // a large stdout write drains to a pipe, truncating JSON at ~64KB (fine to a TTY
79
+ // or file, broken when a parent process captures stdout). Letting the event loop
80
+ // empty naturally flushes the write first.
81
+ process.exitCode = main(process.argv);
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "swimparse",
3
+ "version": "0.1.0",
4
+ "description": "Parses SDIF v3 (.sd3) and Hy-Tek (.hy3) swim-meet result files into one NormalizedMeet JSON contract. Zero dependencies; runs in the browser, Node, and CI.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./sdif": "./src/sdif.js",
10
+ "./hy3": "./src/hy3.js"
11
+ },
12
+ "bin": {
13
+ "swimparse": "cli.js"
14
+ },
15
+ "files": [
16
+ "src/",
17
+ "cli.js",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test"
22
+ },
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/g0rgonus/swimparse.git"
27
+ },
28
+ "sideEffects": false,
29
+ "keywords": [
30
+ "swimming",
31
+ "sdif",
32
+ "sd3",
33
+ "hy3",
34
+ "hy-tek",
35
+ "meet-results",
36
+ "parser",
37
+ "swim-meet"
38
+ ]
39
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared vocabulary for meet-result parsing.
3
+ *
4
+ * Both the SDIF and HY3 adapters map their raw codes onto these canonical
5
+ * values so that a NormalizedMeet looks identical regardless of source format.
6
+ */
7
+
8
+ /** Canonical stroke names. */
9
+ export const STROKE = {
10
+ FREESTYLE: 'Freestyle',
11
+ BACKSTROKE: 'Backstroke',
12
+ BREASTSTROKE: 'Breaststroke',
13
+ BUTTERFLY: 'Butterfly',
14
+ IM: 'IM',
15
+ MEDLEY: 'Medley', // medley relay
16
+ };
17
+
18
+ /** SDIF numeric stroke codes → canonical stroke. */
19
+ export const SDIF_STROKE = {
20
+ '1': STROKE.FREESTYLE,
21
+ '2': STROKE.BACKSTROKE,
22
+ '3': STROKE.BREASTSTROKE,
23
+ '4': STROKE.BUTTERFLY,
24
+ '5': STROKE.IM,
25
+ '6': STROKE.FREESTYLE, // free relay
26
+ '7': STROKE.MEDLEY, // medley relay
27
+ };
28
+
29
+ /** HY3 letter stroke codes → canonical stroke. */
30
+ export const HY3_STROKE = {
31
+ A: STROKE.FREESTYLE,
32
+ B: STROKE.BACKSTROKE,
33
+ C: STROKE.BREASTSTROKE,
34
+ D: STROKE.BUTTERFLY,
35
+ E: STROKE.IM,
36
+ // F/G are diving events
37
+ };
38
+
39
+ /** Raw single-char sex/gender code → canonical. */
40
+ export const GENDER = { M: 'M', F: 'F', X: 'X' };
41
+
42
+ /** Human display for a gender code, in event context. */
43
+ export const GENDER_DISPLAY = { M: 'Boys', F: 'Girls', X: 'Mixed' };
44
+
45
+ /** Course codes → canonical course. */
46
+ export const COURSE = { Y: 'SCY', L: 'LCM', S: 'SCM' };
47
+
48
+ /**
49
+ * Result status. A swim that "counts" is `ok`; everything else is excluded
50
+ * from scoring, but all of them are recorded — consumers filter.
51
+ * @typedef {'ok'|'dq'|'ns'|'dnf'|'scratch'|'exhibition'} ResultStatus
52
+ */
53
+
54
+ /**
55
+ * Parses a 4-char SDIF/HY3-style age code into a labelled age group.
56
+ * Examples: "0910" → 9-10, "UN10" → 10 & Under, "UNOV" → Open.
57
+ *
58
+ * @param {string} lowerStr - 2-char lower bound ("09", "UN", "15", ...)
59
+ * @param {string} upperStr - 2-char upper bound ("10", "OV", "18", ...)
60
+ * @returns {{ label: string, lower: number, upper: number }}
61
+ */
62
+ export function ageGroup(lowerStr, upperStr) {
63
+ const lo = (lowerStr || '').trim();
64
+ const hi = (upperStr || '').trim();
65
+
66
+ if ((lo === 'UN' || lo === '0' || lo === '') && (hi === 'OV' || hi === '109' || hi === '')) {
67
+ return { label: 'Open', lower: 0, upper: 99 };
68
+ }
69
+ if (lo === 'UN' || lo === '0' || lo === '') {
70
+ const upper = parseInt(hi, 10);
71
+ return Number.isNaN(upper)
72
+ ? { label: 'Open', lower: 0, upper: 99 }
73
+ : { label: `${upper} & Under`, lower: 0, upper };
74
+ }
75
+ if (hi === 'OV' || hi === '109') {
76
+ const lower = parseInt(lo, 10);
77
+ return Number.isNaN(lower)
78
+ ? { label: 'Open', lower: 0, upper: 99 }
79
+ : { label: `${lower} & Over`, lower, upper: 99 };
80
+ }
81
+ const lower = parseInt(lo, 10);
82
+ const upper = parseInt(hi, 10);
83
+ if (Number.isNaN(lower) || Number.isNaN(upper)) {
84
+ return { label: 'Open', lower: 0, upper: 99 };
85
+ }
86
+ return { label: lower === upper ? `${lower}` : `${lower}-${upper}`, lower, upper };
87
+ }
package/src/detect.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Format detection. Prefers content sniffing over file extension, since both
3
+ * formats are plain text and extensions are sometimes wrong.
4
+ */
5
+
6
+ /**
7
+ * @param {string} content
8
+ * @param {string} [filename] optional, used only as a tie-breaker
9
+ * @returns {'sdif-v3'|'hy3'|null}
10
+ */
11
+ export function detectFormat(content, filename) {
12
+ const firstLines = String(content).split(/\r?\n/, 5);
13
+ for (const line of firstLines) {
14
+ const code = line.slice(0, 2);
15
+ // HY3 files open with an A1 file-description record.
16
+ if (code === 'A1') return 'hy3';
17
+ // SDIF v3 files open with an A0 file record (or at least carry B1).
18
+ if (code === 'A0' || code === 'B1') return 'sdif-v3';
19
+ }
20
+ if (firstLines.some((l) => l.startsWith('B11') || l.startsWith('D0') || l.startsWith('D3'))) return 'sdif-v3';
21
+ if (firstLines.some((l) => l.startsWith('D1') || l.startsWith('E1'))) return 'hy3';
22
+
23
+ if (filename) {
24
+ const ext = filename.toLowerCase().split('.').pop();
25
+ if (ext === 'hy3') return 'hy3';
26
+ if (ext === 'sd3' || ext === 'cl2' || ext === 'txt') return 'sdif-v3';
27
+ }
28
+ return null;
29
+ }
package/src/hy3.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Hy-Tek (.hy3) adapter → NormalizedMeet.
3
+ *
4
+ * 130-char fixed-width records (128 data + 2 checksum), CP-1252, CRLF.
5
+ * Record types handled:
6
+ * A1 file · B1 meet · C1 team · D1 athlete
7
+ * E1 entry / E2 result (individual, paired)
8
+ * F1 entry / F2 result / F3 legs (relay)
9
+ * G1 splits · H1 DQ reason
10
+ *
11
+ * Two things HY3 gives us that SDIF does not: it RETAINS the swum time on a DQ,
12
+ * and it carries a structured DQ reason (H1). One thing it lacks: meet
13
+ * points — Hy-Tek computes standings separately — so result.points is 0 here and
14
+ * must be derived by a consumer from place + league scoring rules.
15
+ *
16
+ * Offsets for D1/E1/E2/F3 were verified empirically against a real GG-at-WW file.
17
+ */
18
+
19
+ import { HY3_STROKE, STROKE, GENDER_DISPLAY, ageGroup } from './constants.js';
20
+ import { timeFromSeconds, normalizeDate } from './times.js';
21
+ import { displayTeamCode, deriveSwimmers } from './model.js';
22
+
23
+ const slice = (line, a, b) => (line.length >= a ? line.slice(a, b).trim() : '');
24
+ const num = (s) => {
25
+ const v = parseFloat(s);
26
+ return Number.isNaN(v) || v <= 0 ? null : v;
27
+ };
28
+
29
+ // "Last, First M" — append the middle initial when present, matching how SDIF
30
+ // packs its D0 name field so the two formats produce identical display names.
31
+ const athleteName = (a) => {
32
+ if (!a) return '';
33
+ const base = a.first ? `${a.last}, ${a.first}` : a.last;
34
+ return a.middle ? `${base} ${a.middle}` : base;
35
+ };
36
+
37
+ // Event-sex code (W/M/G/B/X) → canonical gender.
38
+ const EVENT_SEX = { M: 'M', B: 'M', W: 'F', G: 'F', F: 'F', X: 'X' };
39
+
40
+ // E2/F2 status char → canonical ResultStatus.
41
+ const STATUS = { ' ': 'ok', '': 'ok', Q: 'dq', F: 'ns', R: 'scratch', D: 'dnf', S: 'exhibition' };
42
+
43
+ // On a RELAY, HY3 reuses the stroke letters differently: A = Freestyle relay,
44
+ // E = Medley relay (vs individual E = IM). Map relays explicitly.
45
+ const HY3_RELAY_STROKE = { A: STROKE.FREESTYLE, E: STROKE.MEDLEY };
46
+
47
+ /**
48
+ * @param {string} content raw .hy3 text
49
+ * @returns {import('./model.js').NormalizedMeet}
50
+ */
51
+ export function parseHy3(content) {
52
+ const lines = String(content).split(/\r?\n/);
53
+
54
+ /** @type {import('./model.js').MeetInfo} */
55
+ const meet = { name: '', rawName: '', startDate: null };
56
+ const teamMap = new Map();
57
+ const eventMap = new Map();
58
+ /** @type {Map<string, {last:string,first:string,gender:string,birthDate:string|null,usasId:string|null}>} */
59
+ const athletes = new Map();
60
+ const source = {};
61
+
62
+ let currentTeam = null;
63
+ let currentAthlete = null; // athlete number
64
+ let lastResult = null; // most recent individual or relay result (for splits / DQ reason)
65
+ let lastRelay = null; // most recent relay (for F3 legs)
66
+
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const line = lines[i];
69
+ const code = line.slice(0, 2);
70
+ try {
71
+ switch (code) {
72
+ case 'A1':
73
+ source.software = slice(line, 44, 58) || undefined;
74
+ source.createdAt = normalizeDate(slice(line, 58, 66));
75
+ break;
76
+ case 'B1':
77
+ meet.rawName = slice(line, 2, 47);
78
+ meet.name = meet.rawName;
79
+ meet.startDate = normalizeDate(slice(line, 92, 100));
80
+ break;
81
+ case 'C1': {
82
+ const raw = slice(line, 2, 7);
83
+ currentTeam = raw;
84
+ if (!teamMap.has(raw)) {
85
+ teamMap.set(raw, { code: displayTeamCode(raw), fullCode: raw, name: slice(line, 7, 37) });
86
+ }
87
+ break;
88
+ }
89
+ case 'D1': {
90
+ const anum = slice(line, 3, 8);
91
+ currentAthlete = anum;
92
+ athletes.set(anum, {
93
+ last: slice(line, 8, 28),
94
+ first: slice(line, 28, 48),
95
+ preferred: slice(line, 48, 68) || undefined,
96
+ middle: slice(line, 68, 69) || undefined,
97
+ gender: slice(line, 2, 3),
98
+ birthDate: normalizeDate(slice(line, 88, 96)),
99
+ usasId: slice(line, 69, 83) || null,
100
+ team: currentTeam,
101
+ });
102
+ break;
103
+ }
104
+ case 'E1': {
105
+ const e2 = lines[i + 1] && lines[i + 1].slice(0, 2) === 'E2' ? lines[i + 1] : '';
106
+ lastResult = parseIndividual(line, e2, eventMap, athletes, currentAthlete, teamMap);
107
+ lastRelay = null;
108
+ break;
109
+ }
110
+ case 'F1': {
111
+ const f2 = lines[i + 1] && lines[i + 1].slice(0, 2) === 'F2' ? lines[i + 1] : '';
112
+ lastRelay = parseRelay(line, f2, eventMap, currentTeam, teamMap);
113
+ lastResult = lastRelay;
114
+ break;
115
+ }
116
+ case 'F3':
117
+ if (lastRelay) parseRelayLegs(line, lastRelay, athletes);
118
+ break;
119
+ case 'H1':
120
+ if (lastResult && lastResult.disqualified) {
121
+ lastResult.dqCode = slice(line, 2, 4);
122
+ lastResult.dqReason = slice(line, 4, 52);
123
+ }
124
+ break;
125
+ }
126
+ } catch {
127
+ /* skip malformed line */
128
+ }
129
+ }
130
+
131
+ const events = [...eventMap.values()];
132
+ for (const ev of events) ev.results.sort((a, b) => (a.place ?? 999) - (b.place ?? 999));
133
+ const teams = [...teamMap.values()];
134
+
135
+ // Enrich the derived registry with athlete-level gender/usasId from D1.
136
+ const enrich = new Map();
137
+ for (const a of athletes.values()) {
138
+ const key = `${a.last}|${a.first}|${a.birthDate || ''}`.toLowerCase();
139
+ enrich.set(key, {
140
+ gender: a.gender === 'M' || a.gender === 'F' ? a.gender : undefined,
141
+ usasId: a.usasId,
142
+ preferredName: a.preferred,
143
+ });
144
+ }
145
+ const swimmers = deriveSwimmers(events, enrich);
146
+
147
+ return { format: 'hy3', source, meet, teams, swimmers, events };
148
+ }
149
+
150
+ function parseIndividual(e1, e2, eventMap, athletes, anum, teamMap) {
151
+ const eventNum = slice(e1, 38, 42);
152
+ const ev = ensureEvent(eventMap, buildEvent(e1, 'individual', { sex: [14, 15], dist: [15, 21], stroke: [21, 22], age: [22, 28] }), eventNum);
153
+
154
+ const a = athletes.get(anum) || { last: slice(e1, 8, 13), first: '', birthDate: null, team: null };
155
+ const swimmerName = athleteName(a);
156
+ const status = STATUS[e2 ? e2[12] : ' '] || 'ok';
157
+ const seconds = e2 ? num(e2.slice(4, 11)) : null;
158
+ const place = e2 ? parseInt(slice(e2, 30, 33), 10) : NaN;
159
+
160
+ /** @type {import('./model.js').IndividualResult} */
161
+ const result = {
162
+ kind: 'individual',
163
+ swimmerName,
164
+ teamCode: teamMap.get(a.team)?.code || a.team || '',
165
+ birthDate: a.birthDate,
166
+ seedTime: timeFromSeconds(num(e1.slice(52, 59))),
167
+ finalTime: timeFromSeconds(seconds), // retained even on DQ (HY3 keeps it)
168
+ status,
169
+ disqualified: status === 'dq',
170
+ place: Number.isNaN(place) ? null : place || null,
171
+ points: 0, // HY3 stores no points; deriving them is a consumer concern
172
+ };
173
+ ev.results.push(result);
174
+ return result;
175
+ }
176
+
177
+ function parseRelay(f1, f2, eventMap, currentTeam, teamMap) {
178
+ const eventNum = slice(f1, 38, 42);
179
+ const ev = ensureEvent(eventMap, buildEvent(f1, 'relay', { sex: [14, 15], dist: [18, 21], stroke: [21, 22], age: [22, 28] }), eventNum);
180
+
181
+ const status = STATUS[f2 ? f2[12] : ' '] || 'ok';
182
+ const seconds = f2 ? num(f2.slice(5, 11)) : null;
183
+ const place = f2 ? parseInt(slice(f2, 30, 33), 10) : NaN;
184
+
185
+ /** @type {import('./model.js').RelayResult} */
186
+ const relay = {
187
+ kind: 'relay',
188
+ teamCode: teamMap.get(currentTeam)?.code || currentTeam || slice(f1, 2, 6),
189
+ relayLetter: slice(f1, 7, 8),
190
+ seedTime: timeFromSeconds(num(f1.slice(52, 59))),
191
+ finalTime: timeFromSeconds(seconds),
192
+ status,
193
+ disqualified: status === 'dq',
194
+ place: Number.isNaN(place) ? null : place || null,
195
+ points: 0,
196
+ legs: [],
197
+ };
198
+ ev.results.push(relay);
199
+ return relay;
200
+ }
201
+
202
+ function parseRelayLegs(f3, relay, athletes) {
203
+ // Up to 8 slots of 13 chars starting at col 3 (index 2).
204
+ for (let off = 2; off + 13 <= f3.length; off += 13) {
205
+ const slot = f3.slice(off, off + 13);
206
+ const anum = slot.slice(1, 6).trim();
207
+ if (!anum) continue;
208
+ const leg = parseInt(slot.slice(12, 13), 10);
209
+ const a = athletes.get(anum);
210
+ relay.legs.push({
211
+ name: a ? athleteName(a) : slot.slice(6, 11).trim(),
212
+ gender: a && (a.gender === 'M' || a.gender === 'F') ? a.gender : undefined,
213
+ legOrder: Number.isNaN(leg) ? relay.legs.length + 1 : leg,
214
+ });
215
+ }
216
+ }
217
+
218
+ function buildEvent(line, type, off) {
219
+ const gender = EVENT_SEX[line[off.sex[0]]] || 'X';
220
+ const distance = parseInt(slice(line, off.dist[0], off.dist[1]), 10) || 0;
221
+ const strokeCode = line[off.stroke[0]];
222
+ const strokeMap = type === 'relay' ? HY3_RELAY_STROKE : HY3_STROKE;
223
+ const stroke = strokeMap[strokeCode] || `Stroke ${strokeCode}`;
224
+ const ageRaw = slice(line, off.age[0], off.age[1]).split(/\s+/).filter(Boolean);
225
+ const ag = ageGroup(ageRaw[0], ageRaw[1]);
226
+ const agLabel = type === 'relay' && ag.label === 'Open' ? '' : ag.label;
227
+ const description = `${GENDER_DISPLAY[gender]} ${agLabel} ${distance}m ${stroke}${type === 'relay' ? ' Relay' : ''}`
228
+ .replace(/\s+/g, ' ')
229
+ .trim();
230
+ return { type, gender, distance, stroke, ageGroup: ag, description, results: [] };
231
+ }
232
+
233
+ function ensureEvent(eventMap, built, rawNumber) {
234
+ const numbered = rawNumber && rawNumber !== '0';
235
+ const key = numbered ? rawNumber : `u:${built.description}`;
236
+ let ev = eventMap.get(key);
237
+ if (!ev) {
238
+ ev = { number: numbered ? rawNumber : '', ...built };
239
+ eventMap.set(key, ev);
240
+ }
241
+ return ev;
242
+ }
package/src/index.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * swimparse — public entry point.
3
+ *
4
+ * Parse SDIF v3 (.sd3) or Hy-Tek (.hy3) meet results into one NormalizedMeet.
5
+ * Zero dependencies; runs in the browser, Node, and CI.
6
+ *
7
+ * import { parse, detectFormat } from 'swimparse';
8
+ * const meet = parse(fileText, { filename: 'GG_at_WW.hy3' });
9
+ *
10
+ * SCOPE: this library reads meet-result files and emits JSON. That is all it
11
+ * does. It has no concept of a league — no age bands, no scoring rules, no team
12
+ * registry, no qualifying standards. Those are league policy and belong to the
13
+ * application consuming this output.
14
+ *
15
+ * PRIVACY: the output is a lossless superset of the source file, so it CARRIES
16
+ * SWIMMER BIRTHDATES (`swimmers[].birthDate`, individual `results[].birthDate`)
17
+ * and USA-S registration ids. For youth meets that is PII for minors — treat
18
+ * every parse result as confidential until your application has stripped or
19
+ * aggregated it. swimparse deliberately does not do that for you: what counts as
20
+ * safe is a league decision (a summer league publishes age-group labels; a
21
+ * USA-Swimming tool needs exact ages), so it belongs to the consumer.
22
+ */
23
+
24
+ import { parseSdif } from './sdif.js';
25
+ import { parseHy3 } from './hy3.js';
26
+ import { detectFormat } from './detect.js';
27
+
28
+ export { parseSdif } from './sdif.js';
29
+ export { parseHy3 } from './hy3.js';
30
+ export { detectFormat } from './detect.js';
31
+ export * from './model.js';
32
+ export * from './constants.js';
33
+ export * from './times.js';
34
+
35
+ /**
36
+ * Parses meet-result text, auto-detecting the format unless one is given.
37
+ *
38
+ * @param {string} content
39
+ * @param {Object} [opts]
40
+ * @param {'sdif-v3'|'hy3'} [opts.format] force a format, skipping detection
41
+ * @param {string} [opts.filename] used as a detection tie-breaker
42
+ * @returns {import('./model.js').NormalizedMeet}
43
+ */
44
+ export function parse(content, opts = {}) {
45
+ const format = opts.format || detectFormat(content, opts.filename);
46
+ if (format === 'sdif-v3') return parseSdif(content);
47
+ if (format === 'hy3') return parseHy3(content);
48
+ throw new Error('swimparse: could not detect meet-result format (expected SDIF .sd3 or Hy-Tek .hy3)');
49
+ }
package/src/model.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The NormalizedMeet contract.
3
+ *
4
+ * The single shape both adapters produce, so consumers read one JSON contract
5
+ * instead of two fixed-width formats.
6
+ *
7
+ * Design rule: **lossless superset**. Capture every swim — placing or not,
8
+ * exhibition, DQ, no-show — plus birthdates, seed times, splits and DQ reasons.
9
+ * Consumers filter down to what they need. Never drop data at parse time.
10
+ *
11
+ * Everything here is read out of the file. Nothing is inferred, computed, or
12
+ * relabelled according to any league's rules: no age banding, no scoring, no
13
+ * team-code canonicalization, no synthesized meet names. Those are consumer
14
+ * concerns.
15
+ *
16
+ * PRIVACY: `swimmers[].birthDate` and `usasId` are PII for minors, and the
17
+ * lossless rule means they are always populated when the file carries them. A
18
+ * NormalizedMeet is therefore a confidential artifact by default. Stripping or
19
+ * aggregating it is the consumer's job — see the privacy note in index.js.
20
+ */
21
+
22
+ /**
23
+ * @typedef {import('./times.js').SwimTime} SwimTime
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} NormalizedMeet
28
+ * @property {'sdif-v3'|'hy3'} format Source format the file was parsed from.
29
+ * @property {Object} source Producing software (A0/A1 record).
30
+ * @property {string} [source.software]
31
+ * @property {string} [source.version]
32
+ * @property {string} [source.createdAt] ISO date if available.
33
+ * @property {MeetInfo} meet
34
+ * @property {Team[]} teams
35
+ * @property {Swimmer[]} swimmers Deduped registry (name + birthdate).
36
+ * @property {Event[]} events
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} MeetInfo
41
+ * @property {string} name Meet name as it appeared in the file.
42
+ * @property {string} rawName Identical to `name`; kept so consumers
43
+ * that relabel a meet have an untouched
44
+ * original to fall back on.
45
+ * @property {string} [hostName]
46
+ * @property {string|null} startDate ISO "YYYY-MM-DD".
47
+ * @property {string|null} [endDate]
48
+ * @property {string|null} [course] 'SCY' | 'LCM' | 'SCM'.
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} Team
53
+ * @property {string} code Display code, VA-prefix stripped (e.g. "WW").
54
+ * @property {string} fullCode Raw code as in the file (e.g. "VAWW").
55
+ * @property {string} name
56
+ * @property {string} [shortName]
57
+ */
58
+
59
+ /**
60
+ * @typedef {Object} Swimmer
61
+ * @property {string} id Stable within-file id.
62
+ * @property {string} teamCode
63
+ * @property {string} lastName
64
+ * @property {string} firstName
65
+ * @property {string} [preferredName]
66
+ * @property {string} [middleInitial]
67
+ * @property {string} fullName "Last, First".
68
+ * @property {'M'|'F'} [gender]
69
+ * @property {string|null} [birthDate] ISO, or null. PII — as read from the file.
70
+ * @property {string|null} [usasId] PII — as read from the file.
71
+ * @property {number|null} [age] Age as stated in the file, when present.
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} Event
76
+ * @property {string} number Event number as a string (may be alphanumeric).
77
+ * @property {'individual'|'relay'} type
78
+ * @property {'M'|'F'|'X'} gender
79
+ * @property {number} distance
80
+ * @property {string} stroke Canonical stroke name.
81
+ * @property {string|null} [course]
82
+ * @property {{label:string, lower:number, upper:number}} ageGroup
83
+ * @property {string} description Human label, e.g. "Boys 15-18 100m IM".
84
+ * @property {(IndividualResult|RelayResult)[]} results
85
+ */
86
+
87
+ /**
88
+ * @typedef {Object} IndividualResult
89
+ * @property {'individual'} kind
90
+ * @property {string} [swimmerId] Links to Swimmer.id when resolvable.
91
+ * @property {string} swimmerName "Last, First" as in the file.
92
+ * @property {string} teamCode
93
+ * @property {string|null} [birthDate] ISO. PII — as read from the file.
94
+ * @property {SwimTime|null} seedTime
95
+ * @property {SwimTime|null} finalTime The time swum. NOTE: for HY3 this is
96
+ * retained even on a DQ; for SDIF it is
97
+ * null on DQ/NS (the format nulls it).
98
+ * @property {import('./constants.js').ResultStatus} status
99
+ * @property {boolean} disqualified
100
+ * @property {string} [dqCode] HY3 only.
101
+ * @property {string} [dqReason] HY3 H1/H2 only.
102
+ * @property {number|null} place null = non-scoring / exhibition.
103
+ * @property {number} [heat]
104
+ * @property {number} [lane]
105
+ * @property {number} points Points as stored in the file (SDIF).
106
+ * HY3 carries none, so it reads 0 —
107
+ * deriving points is a scoring concern
108
+ * and belongs to the consumer.
109
+ * @property {number[]} [splits] Cumulative split seconds (HY3 G1).
110
+ */
111
+
112
+ /**
113
+ * @typedef {Object} RelayResult
114
+ * @property {'relay'} kind
115
+ * @property {string} teamCode
116
+ * @property {string} relayLetter 'A', 'B', ...
117
+ * @property {SwimTime|null} seedTime
118
+ * @property {SwimTime|null} finalTime
119
+ * @property {import('./constants.js').ResultStatus} status
120
+ * @property {boolean} disqualified
121
+ * @property {string} [dqCode]
122
+ * @property {string} [dqReason]
123
+ * @property {number|null} place
124
+ * @property {number} [heat]
125
+ * @property {number} [lane]
126
+ * @property {number} points
127
+ * @property {RelayLeg[]} legs
128
+ * @property {number[]} [splits]
129
+ */
130
+
131
+ /**
132
+ * @typedef {Object} RelayLeg
133
+ * @property {string} [swimmerId]
134
+ * @property {string} name
135
+ * @property {'M'|'F'} [gender]
136
+ * @property {number} [age]
137
+ * @property {number} legOrder 1-4 (or higher for alternates).
138
+ */
139
+
140
+ /**
141
+ * Strips a two-letter LSC/state prefix (e.g. "VAWW" → "WW") from a raw team code.
142
+ *
143
+ * This is an SDIF/HY3 file convention, not a league rule: US meet files prefix
144
+ * team codes with the LSC. `Team.fullCode` always keeps the raw value, so a
145
+ * consumer that wants the prefix back has it. Mapping either form onto a
146
+ * league's canonical code is the consumer's job.
147
+ *
148
+ * @param {string} rawCode
149
+ * @returns {string}
150
+ */
151
+ export function displayTeamCode(rawCode) {
152
+ const c = (rawCode || '').trim();
153
+ return c.startsWith('VA') ? c.slice(2) : c;
154
+ }
155
+
156
+ /**
157
+ * Builds the deduped swimmer registry from individual results.
158
+ *
159
+ * Keyed on `lastName|firstName|birthDate`, the strongest identity the file
160
+ * itself supports (middle name is excluded because it drifts between exports).
161
+ * This is a **within-file** key only — durable cross-meet or cross-season
162
+ * athlete identity is a consumer concern and cannot be decided here.
163
+ *
164
+ * @param {Event[]} events
165
+ * @param {Map<string, Partial<Swimmer>>} [enrich] optional id→extra fields (D3/D1 data)
166
+ * @returns {Swimmer[]}
167
+ */
168
+ export function deriveSwimmers(events, enrich) {
169
+ /** @type {Map<string, Swimmer>} */
170
+ const byKey = new Map();
171
+ for (const ev of events) {
172
+ if (ev.type !== 'individual') continue;
173
+ const gender = ev.gender === 'X' ? undefined : ev.gender;
174
+ for (const r of ev.results) {
175
+ const [last, first] = splitName(r.swimmerName);
176
+ const key = `${last}|${first}|${r.birthDate || ''}`.toLowerCase();
177
+ if (!byKey.has(key)) {
178
+ byKey.set(key, {
179
+ id: key,
180
+ teamCode: r.teamCode,
181
+ lastName: last,
182
+ firstName: first,
183
+ fullName: r.swimmerName,
184
+ gender,
185
+ birthDate: r.birthDate || null,
186
+ usasId: null,
187
+ });
188
+ }
189
+ r.swimmerId = key;
190
+ }
191
+ }
192
+ if (enrich) {
193
+ for (const s of byKey.values()) {
194
+ const extra = enrich.get(s.id);
195
+ if (extra) Object.assign(s, extra);
196
+ }
197
+ }
198
+ return [...byKey.values()];
199
+ }
200
+
201
+ /**
202
+ * Splits "Last, First M" into ["Last", "First"] (middle dropped).
203
+ * @param {string} name
204
+ * @returns {[string, string]}
205
+ */
206
+ export function splitName(name) {
207
+ const parts = String(name || '').split(',');
208
+ const last = (parts[0] || '').trim();
209
+ const first = (parts[1] || '').trim().split(/\s+/)[0] || '';
210
+ return [last, first];
211
+ }
package/src/sdif.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * SDIF v3 (.sd3) adapter → NormalizedMeet.
3
+ *
4
+ * Fixed-width text. Record types handled:
5
+ * B1 meet · B2 host · C1 team · D0 individual result · D3 swimmer reg
6
+ * E0 relay result · F0 relay swimmer
7
+ *
8
+ * Keeps EVERY individual/relay result (exhibition, DQ, no-show, non-placing)
9
+ * — the lossless-superset rule.
10
+ */
11
+
12
+ import { SDIF_STROKE, GENDER, GENDER_DISPLAY, ageGroup } from './constants.js';
13
+ import { timeFromText, normalizeDate } from './times.js';
14
+ import { displayTeamCode, deriveSwimmers } from './model.js';
15
+
16
+ // Column offsets (0-indexed, [start, end)).
17
+ const OFF = {
18
+ D0: { name: [11, 39], birth: [55, 63], seed: [88, 96], final: [115, 123], event: [72, 76], place: [135, 138], points: [138, 142] },
19
+ D0evt: { gender: [66, 67], dist: [67, 71], stroke: [71, 72], age: [76, 80] },
20
+ E0: { letter: [11, 12], final: [72, 80], event: [26, 30], place: [92, 95], points: [95, 99] },
21
+ E0evt: { gender: [20, 21], dist: [21, 25], stroke: [25, 26], age: [30, 34] },
22
+ F0: { name: [22, 50] },
23
+ };
24
+
25
+ const slice = (line, [a, b]) => (line.length >= a ? line.slice(a, b).trim() : '');
26
+
27
+ /**
28
+ * @param {string} content raw .sd3 text
29
+ * @returns {import('./model.js').NormalizedMeet}
30
+ */
31
+ export function parseSdif(content) {
32
+ const lines = String(content).split(/\r?\n/);
33
+
34
+ /** @type {import('./model.js').MeetInfo} */
35
+ const meet = { name: '', rawName: '', startDate: null };
36
+ /** @type {Map<string, import('./model.js').Team>} */
37
+ const teamMap = new Map();
38
+ /** @type {Map<string, import('./model.js').Event>} */
39
+ const eventMap = new Map();
40
+
41
+ let currentTeam = null; // raw code
42
+ let lastRelay = null;
43
+
44
+ const source = {};
45
+
46
+ for (const line of lines) {
47
+ const code = line.slice(0, 2);
48
+ try {
49
+ switch (code) {
50
+ case 'A0':
51
+ source.software = slice(line, [43, 63]) || undefined;
52
+ break;
53
+ case 'B1':
54
+ meet.rawName = slice(line, [11, 41]);
55
+ meet.name = meet.rawName;
56
+ meet.startDate = normalizeDate(slice(line, [121, 129]));
57
+ lastRelay = null;
58
+ break;
59
+ case 'B2':
60
+ if (!meet.hostName) meet.hostName = slice(line, [11, 41]);
61
+ break;
62
+ case 'C1': {
63
+ const raw = slice(line, [11, 17]);
64
+ currentTeam = raw;
65
+ if (!teamMap.has(raw)) {
66
+ teamMap.set(raw, {
67
+ code: displayTeamCode(raw),
68
+ fullCode: raw,
69
+ name: slice(line, [17, 47]),
70
+ });
71
+ }
72
+ lastRelay = null;
73
+ break;
74
+ }
75
+ case 'D0':
76
+ lastRelay = null;
77
+ parseD0(line, eventMap, currentTeam, teamMap);
78
+ break;
79
+ case 'E0':
80
+ lastRelay = parseE0(line, eventMap, currentTeam, teamMap);
81
+ break;
82
+ case 'F0':
83
+ if (lastRelay) {
84
+ const name = slice(line, OFF.F0.name);
85
+ if (name) lastRelay.legs.push({ name, legOrder: lastRelay.legs.length + 1 });
86
+ }
87
+ break;
88
+ }
89
+ } catch {
90
+ /* skip malformed line */
91
+ }
92
+ }
93
+
94
+ const events = [...eventMap.values()];
95
+ for (const ev of events) {
96
+ ev.results.sort((a, b) => (a.place ?? 999) - (b.place ?? 999));
97
+ }
98
+ const teams = [...teamMap.values()];
99
+ const swimmers = deriveSwimmers(events);
100
+
101
+ return { format: 'sdif-v3', source, meet, teams, swimmers, events };
102
+ }
103
+
104
+ function statusFromText(raw) {
105
+ const t = String(raw).trim().toUpperCase();
106
+ if (t.startsWith('DQ')) return 'dq';
107
+ if (t.startsWith('NS')) return 'ns';
108
+ if (t.startsWith('DNF')) return 'dnf';
109
+ if (t.startsWith('SCR')) return 'scratch';
110
+ return 'ok';
111
+ }
112
+
113
+ function parseD0(line, eventMap, currentTeam, teamMap) {
114
+ if (!currentTeam) return;
115
+ // Event number 0 = an unseeded/unofficial event (e.g. 8 & Under "B" relays).
116
+ // Keep it (lossless), but bucket unnumbered events by description so two
117
+ // distinct ones don't merge under a shared "0".
118
+ const eventNum = slice(line, OFF.D0.event);
119
+ const ev = ensureEvent(eventMap, buildEvent(line, 'individual', OFF.D0evt), eventNum);
120
+
121
+ const finalRaw = slice(line, OFF.D0.final);
122
+ const status = statusFromText(finalRaw);
123
+ const placeNum = parseInt(slice(line, OFF.D0.place), 10);
124
+
125
+ /** @type {import('./model.js').IndividualResult} */
126
+ ev.results.push({
127
+ kind: 'individual',
128
+ swimmerName: slice(line, OFF.D0.name),
129
+ teamCode: teamMap.get(currentTeam)?.code || currentTeam,
130
+ birthDate: normalizeDate(slice(line, OFF.D0.birth)),
131
+ seedTime: timeFromText(slice(line, OFF.D0.seed)),
132
+ finalTime: status === 'ok' ? timeFromText(finalRaw) : null,
133
+ status,
134
+ disqualified: status === 'dq',
135
+ place: Number.isNaN(placeNum) ? null : placeNum || null,
136
+ points: parseFloat(slice(line, OFF.D0.points)) || 0,
137
+ });
138
+ }
139
+
140
+ function parseE0(line, eventMap, currentTeam, teamMap) {
141
+ if (!currentTeam) return null;
142
+ const eventNum = slice(line, OFF.E0.event);
143
+ const ev = ensureEvent(eventMap, buildEvent(line, 'relay', OFF.E0evt), eventNum);
144
+
145
+ const finalRaw = slice(line, OFF.E0.final);
146
+ const status = statusFromText(finalRaw);
147
+ const placeNum = parseInt(slice(line, OFF.E0.place), 10);
148
+
149
+ /** @type {import('./model.js').RelayResult} */
150
+ const relay = {
151
+ kind: 'relay',
152
+ teamCode: teamMap.get(currentTeam)?.code || currentTeam,
153
+ relayLetter: slice(line, OFF.E0.letter),
154
+ seedTime: null,
155
+ finalTime: status === 'ok' ? timeFromText(finalRaw) : null,
156
+ status,
157
+ disqualified: status === 'dq',
158
+ place: Number.isNaN(placeNum) ? null : placeNum || null,
159
+ points: parseFloat(slice(line, OFF.E0.points)) || 0,
160
+ legs: [],
161
+ };
162
+ ev.results.push(relay);
163
+ return relay;
164
+ }
165
+
166
+ function buildEvent(line, type, off) {
167
+ const gender = GENDER[slice(line, off.gender)] || 'X';
168
+ const distance = parseInt(slice(line, off.dist), 10) || 0;
169
+ const stroke = SDIF_STROKE[slice(line, off.stroke)] || `Stroke ${slice(line, off.stroke)}`;
170
+ const ageCode = slice(line, off.age).padEnd(4);
171
+ const ag = ageGroup(ageCode.slice(0, 2), ageCode.slice(2, 4));
172
+ const agLabel = type === 'relay' && ag.label === 'Open' ? '' : ag.label;
173
+ const description = `${GENDER_DISPLAY[gender]} ${agLabel} ${distance}m ${stroke}${type === 'relay' ? ' Relay' : ''}`
174
+ .replace(/\s+/g, ' ')
175
+ .trim();
176
+ return { type, gender, distance, stroke, ageGroup: ag, description, results: [] };
177
+ }
178
+
179
+ function ensureEvent(eventMap, built, rawNumber) {
180
+ const numbered = rawNumber && rawNumber !== '0';
181
+ const key = numbered ? rawNumber : `u:${built.description}`;
182
+ let ev = eventMap.get(key);
183
+ if (!ev) {
184
+ ev = { number: numbered ? rawNumber : '', ...built };
185
+ eventMap.set(key, ev);
186
+ }
187
+ return ev;
188
+ }
package/src/times.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Time and date helpers.
3
+ *
4
+ * The two source formats encode times differently — SDIF as display text
5
+ * ("1:11.35"), HY3 as raw seconds ("71.35") — but a NormalizedMeet always
6
+ * carries BOTH: `{ text, seconds }`. These helpers convert either way.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} SwimTime
11
+ * @property {string} text Canonical display, e.g. "1:11.35" or "28.05".
12
+ * @property {number} seconds Total seconds as a float (hundredths precision).
13
+ */
14
+
15
+ /**
16
+ * Parses an SDIF-style time string into seconds.
17
+ * Handles "MM:SS.ss", "SS.ss", optional trailing course letter, and the
18
+ * non-time sentinels DQ / NS / SCR / DNF / NT (→ null).
19
+ *
20
+ * @param {string} raw
21
+ * @returns {number|null} seconds, or null if not a real time
22
+ */
23
+ export function textToSeconds(raw) {
24
+ if (!raw) return null;
25
+ const s = String(raw).trim().replace(/[A-Za-z]+$/, '').trim(); // strip trailing course flag
26
+ if (!s) return null;
27
+ if (/^(DQ|NS|SCR|DNF|NT)$/i.test(String(raw).trim())) return null;
28
+ if (s.includes(':')) {
29
+ const [m, rest] = s.split(':');
30
+ const minutes = parseInt(m, 10);
31
+ const seconds = parseFloat(rest);
32
+ if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
33
+ return round2(minutes * 60 + seconds);
34
+ }
35
+ const v = parseFloat(s);
36
+ return Number.isNaN(v) || v <= 0 ? null : round2(v);
37
+ }
38
+
39
+ /**
40
+ * Formats seconds as canonical display text ("1:11.35", "28.05").
41
+ * @param {number|null} seconds
42
+ * @returns {string}
43
+ */
44
+ export function secondsToText(seconds) {
45
+ if (seconds == null || Number.isNaN(seconds) || seconds <= 0) return '';
46
+ const total = round2(seconds);
47
+ const m = Math.floor(total / 60);
48
+ const s = total - m * 60;
49
+ if (m > 0) return `${m}:${s.toFixed(2).padStart(5, '0')}`;
50
+ return s.toFixed(2);
51
+ }
52
+
53
+ /**
54
+ * Builds a SwimTime from seconds (HY3 path). Returns null if not a real time.
55
+ * @param {number|null} seconds
56
+ * @returns {SwimTime|null}
57
+ */
58
+ export function timeFromSeconds(seconds) {
59
+ if (seconds == null || Number.isNaN(seconds) || seconds <= 0) return null;
60
+ return { text: secondsToText(seconds), seconds: round2(seconds) };
61
+ }
62
+
63
+ /**
64
+ * Builds a SwimTime from display text (SDIF path). Returns null if not a real time.
65
+ * @param {string} text
66
+ * @returns {SwimTime|null}
67
+ */
68
+ export function timeFromText(text) {
69
+ const seconds = textToSeconds(text);
70
+ return seconds == null ? null : { text: secondsToText(seconds), seconds };
71
+ }
72
+
73
+ /**
74
+ * Normalizes an MMDDYYYY (or MMDDYY) date to ISO "YYYY-MM-DD".
75
+ * Two-digit years are windowed: <30 → 20xx, else 19xx.
76
+ * @param {string} raw
77
+ * @returns {string|null}
78
+ */
79
+ export function normalizeDate(raw) {
80
+ if (!raw) return null;
81
+ const d = String(raw).trim();
82
+ if (d.length === 8) {
83
+ const mm = d.slice(0, 2), dd = d.slice(2, 4), yyyy = d.slice(4, 8);
84
+ if (yyyy === '0000' || mm === '00') return null;
85
+ return `${yyyy}-${mm}-${dd}`;
86
+ }
87
+ if (d.length === 6) {
88
+ const mm = d.slice(0, 2), dd = d.slice(2, 4), yy = parseInt(d.slice(4, 6), 10);
89
+ if (Number.isNaN(yy)) return null;
90
+ const yyyy = yy < 30 ? 2000 + yy : 1900 + yy;
91
+ return `${yyyy}-${mm}-${dd}`;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** Rounds to hundredths, avoiding binary-float noise. */
97
+ export function round2(n) {
98
+ return Math.round((n + Number.EPSILON) * 100) / 100;
99
+ }