swimparse 0.1.0 → 0.2.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/README.md CHANGED
@@ -4,6 +4,10 @@ Reads swim-meet result files — **SDIF v3** (`.sd3`) and **Hy-Tek** (`.hy3`)
4
4
  one `NormalizedMeet` JSON shape, so tools consume a single contract instead of
5
5
  re-implementing fixed-width parsing twice.
6
6
 
7
+ It also reads the other end of a meet: **Hy-Tek meet-setup files** (`.ev3` / `.hyv`) —
8
+ the event list, session schedule, entry fees and qualifying cuts — as a
9
+ `NormalizedMeetSetup`.
10
+
7
11
  - **Zero dependencies.** Plain ESM — browser, Node, and CI unchanged.
8
12
  - **Lossless.** Every swim is kept: placing or not, exhibition, DQ, no-show — plus
9
13
  birthdates, seed times, relay legs, splits and DQ reasons.
@@ -22,23 +26,24 @@ everything else.
22
26
 
23
27
  | Concern | Where it belongs |
24
28
  |---|---|
25
- | SDIF / HY3 record layouts, times, dates, format detection | **swimparse** |
26
- | The `NormalizedMeet` contract | **swimparse** |
29
+ | SDIF / HY3 / EV3 / HYV record layouts, times, dates, format detection | **swimparse** |
30
+ | The `NormalizedMeet` and `NormalizedMeetSetup` contracts | **swimparse** |
27
31
  | Age bands, age-up date, age-group labels for swimmers | your league layer |
28
32
  | Scoring: point values, which relays count, team totals | your league layer |
29
33
  | Canonical team codes / alias mapping | your league layer |
30
34
  | Stripping or aggregating PII | your league layer |
31
- | Qualifying standards, records, personal bests | your application |
35
+ | Whether a swimmer meets a cut, course conversions, records, personal bests | your application |
32
36
 
33
37
  ## Install
34
38
 
35
- Consumed as a pinned git dependency:
36
-
37
- ```json
38
- "dependencies": { "swimparse": "github:g0rgonus/swimparse#v0.1.0" }
39
+ ```sh
40
+ npm install swimparse
39
41
  ```
40
42
 
41
- Pin to a tag or SHA for reproducibility. `npm ci` clones it in CI with no publish step.
43
+ Zero dependencies, so there is nothing else to pull in.
44
+
45
+ Releases are published from CI with [provenance](https://docs.npmjs.com/generating-provenance-statements),
46
+ so every version on npm can be traced to the commit and workflow run that built it.
42
47
 
43
48
  ## Usage
44
49
 
@@ -49,12 +54,36 @@ const meet = parse(fileText, { filename: 'GG_at_WW.hy3' }); // auto-detects form
49
54
  const meet = parse(fileText, { format: 'sdif-v3' }); // or force one
50
55
  ```
51
56
 
57
+ Meet-setup files go through `parseSetup` instead — they hold events, not results, so
58
+ they parse to a different shape rather than an empty `NormalizedMeet`:
59
+
60
+ ```js
61
+ import { parseSetup, qualifyingStandards } from 'swimparse';
62
+
63
+ const setup = parseSetup(fileText, { filename: 'Meet Events-2026 Champs.ev3' });
64
+ setup.events[0].qualifyingTimes; // { LCM, SCM, SCY } — the event's cut per course
65
+ qualifyingStandards(setup); // flat cut table: one row per event that has one
66
+ ```
67
+
68
+ `qualifyingTimes` is the file verbatim. `qualifyingStandards()` is the cut table you
69
+ would publish, and makes exactly one judgement the parse does not: a meet that does not
70
+ accept a course may fill that column with a placeholder (`0.01`, `1.00`) on every event
71
+ rather than leaving it blank, and those are dropped along with any row left with
72
+ nothing real.
73
+
74
+ If you are consuming the cuts, read
75
+ **[docs/qualifying-cuts.md](docs/qualifying-cuts.md)** first — the output shape, and the
76
+ four things that are easy to get wrong (course keys, what `null` means, comparing on
77
+ `seconds`, and where the meet's rules end and yours begin).
78
+
52
79
  CLI:
53
80
 
54
81
  ```bash
55
82
  swimparse meet.hy3 --pretty # NormalizedMeet JSON to stdout
56
83
  swimparse meet.sd3 -o meet.json # to a file
57
84
  swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input
85
+ swimparse events.ev3 --pretty # NormalizedMeetSetup JSON
86
+ swimparse events.ev3 --cuts # just the qualifying-time table
58
87
  ```
59
88
 
60
89
  ## The NormalizedMeet contract
@@ -83,6 +112,40 @@ for the full typedefs. Highlights:
83
112
  | Points | stored | absent (reads `0`) |
84
113
  | Names | 28-char field | wider, less truncation |
85
114
 
115
+ ## The NormalizedMeetSetup contract
116
+
117
+ `{ format, source, meet, sessions, events }` — see [`src/model.js`](src/model.js). A
118
+ setup file is the meet before anyone has entered it, so `events[]` here are event
119
+ *definitions*, not results.
120
+
121
+ - **`event.qualifyingTimes`** is `{ LCM, SCM, SCY }` — the same cut expressed in each
122
+ course, each a `SwimTime` or `null`. A meet that sets no cuts (most invitationals)
123
+ parses to all-null, and `qualifyingStandards()` returns `[]`. The two file flavours
124
+ store those three columns in different orders — the `.hyv` rotates them to start at
125
+ the meet's own course — so read them from here, keyed by course, rather than by
126
+ column position.
127
+ - **`event.course`** is the event's own course as stated in the file (`.ev3` only).
128
+ - **`meet.qualifyingSince`** is the start of the period a cut may be swum in (`.ev3`
129
+ only). Inferred from the files rather than from a spec — see `src/setup.js`.
130
+ - **`event.round`** is `finals` or `prelims`; `rounds` is 1 for timed finals, 2 for
131
+ prelims-plus-finals (`.ev3` only).
132
+ - **`sessions`** is the day/start-time schedule, collapsed out of the per-event stamps.
133
+ A session id may be alphanumeric (`"2G"` — session 2, girls). `.hyv` carries no
134
+ schedule, so it parses to `[]`.
135
+ - **Event numbers keep their age-group letter** (`"1A"`, `"1B"`, `"1C"`).
136
+
137
+ ### ev3 vs hyv
138
+
139
+ Meet Manager exports both together in one zip and they describe the same events. The
140
+ `.ev3` is the richer file — sessions, day, event order, start times, relay legs,
141
+ sanction number, venue address, entry deadline. The `.hyv` is the Team Manager import
142
+ file: events, ages, fees and cuts only. swimparse parses both, and the test suite
143
+ asserts they agree event-for-event.
144
+
145
+ Note that SDIF also defines an `.ev3` meet-events file, which is fixed-width and a
146
+ different format. Detection sniffs content, so a fixed-width `.ev3` still routes to
147
+ the SDIF adapter.
148
+
86
149
  ## Privacy
87
150
 
88
151
  The lossless rule means output carries **`swimmers[].birthDate` and `usasId` whenever the
@@ -96,10 +159,13 @@ needs exact ages). Putting that choice in the parser would force one answer on e
96
159
  Test fixtures in this repo are synthetic — every identity is a public figure with a
97
160
  shifted birth year; see [`test/fixtures/README.md`](test/fixtures/README.md).
98
161
 
162
+ **Meet-setup files are the exception**: they contain no swimmers at all, so a
163
+ `NormalizedMeetSetup` is safe to publish as-is.
164
+
99
165
  ## Tests
100
166
 
101
167
  ```bash
102
- node --test # golden snapshots + SDIF↔HY3 cross-agreement
168
+ node --test # golden snapshots + SDIF↔HY3 and EV3↔HYV cross-agreement
103
169
  ```
104
170
 
105
171
  ## License
package/cli.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * swimparse CLI — turn .sd3/.hy3 files into NormalizedMeet JSON.
3
+ * swimparse CLI — turn .sd3/.hy3 results, or .ev3/.hyv meet setups, into JSON.
4
4
  *
5
5
  * swimparse meet.hy3 # JSON to stdout
6
6
  * swimparse meet.sd3 -o meet.json # JSON to a file
7
7
  * swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input, into out/
8
8
  * swimparse meet.hy3 --pretty # 2-space indented
9
+ * swimparse events.ev3 # NormalizedMeetSetup JSON
10
+ * swimparse events.ev3 --cuts # just the qualifying-time table
9
11
  *
10
12
  * PRIVACY: the output contains swimmer birthdates and registration ids exactly
11
13
  * as the source file carries them. For a youth meet that is PII for minors —
@@ -14,7 +16,7 @@
14
16
 
15
17
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
18
  import { basename, join, extname } from 'node:path';
17
- import { parse } from './src/index.js';
19
+ import { parse, parseSetup, detectFormat, qualifyingStandards } from './src/index.js';
18
20
 
19
21
  function main(argv) {
20
22
  const args = argv.slice(2);
@@ -22,12 +24,14 @@ function main(argv) {
22
24
  let outFile = null;
23
25
  let outDir = null;
24
26
  let pretty = false;
27
+ let cuts = false;
25
28
 
26
29
  for (let i = 0; i < args.length; i++) {
27
30
  const a = args[i];
28
31
  if (a === '-o' || a === '--out') outFile = args[++i];
29
32
  else if (a === '-d' || a === '--out-dir') outDir = args[++i];
30
33
  else if (a === '--pretty') pretty = true;
34
+ else if (a === '--cuts') cuts = true;
31
35
  else if (a === '-h' || a === '--help') return help(0);
32
36
  else if (a.startsWith('-')) return fail(`unknown option: ${a}`);
33
37
  else inputs.push(a);
@@ -39,15 +43,21 @@ function main(argv) {
39
43
  if (outDir) mkdirSync(outDir, { recursive: true });
40
44
 
41
45
  for (const file of inputs) {
42
- const meet = parse(readFileSync(file, 'latin1'), { filename: file });
43
- const json = JSON.stringify(meet, null, indent);
46
+ const content = readFileSync(file, 'latin1');
47
+ const format = detectFormat(content, file);
48
+ const isSetup = format === 'ev3' || format === 'hyv';
49
+ if (cuts && !isSetup) return fail(`--cuts needs a meet-setup file (.ev3/.hyv); ${file} is ${format || 'unrecognized'}`);
50
+
51
+ const meet = isSetup ? parseSetup(content, { filename: file }) : parse(content, { filename: file });
52
+ const json = JSON.stringify(cuts ? qualifyingStandards(meet) : meet, null, indent);
53
+ const summary = `${meet.format}, ${meet.events.length} events`;
44
54
  if (outDir) {
45
55
  const name = basename(file, extname(file)) + '.json';
46
56
  writeFileSync(join(outDir, name), json);
47
- process.stderr.write(`wrote ${join(outDir, name)} (${meet.format}, ${meet.events.length} events)\n`);
57
+ process.stderr.write(`wrote ${join(outDir, name)} (${summary})\n`);
48
58
  } else if (outFile) {
49
59
  writeFileSync(outFile, json);
50
- process.stderr.write(`wrote ${outFile} (${meet.format}, ${meet.events.length} events)\n`);
60
+ process.stderr.write(`wrote ${outFile} (${summary})\n`);
51
61
  } else {
52
62
  process.stdout.write(json + '\n');
53
63
  }
@@ -57,15 +67,18 @@ function main(argv) {
57
67
 
58
68
  function help(codeNum) {
59
69
  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' +
70
+ 'Usage: swimparse <file...> [-o out.json | -d out-dir] [--pretty] [--cuts]\n' +
71
+ ' Parses SDIF (.sd3) or Hy-Tek (.hy3) results into NormalizedMeet JSON, and\n' +
72
+ ' Hy-Tek meet-setup files (.ev3/.hyv) into NormalizedMeetSetup JSON.\n' +
62
73
  ' -o, --out <path> write a single input to this file\n' +
63
74
  ' -d, --out-dir <dir> write one <name>.json per input into this directory\n' +
64
75
  ' --pretty 2-space indented JSON\n' +
76
+ ' --cuts setup files only: emit just the qualifying-time table\n' +
65
77
  '\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'
78
+ ' Result output carries swimmer birthdates as they appear in the file —\n' +
79
+ ' sanitize before publishing. Setup files contain no personal data.\n' +
80
+ ' Age banding, scoring, and team-code mapping are league policy and are\n' +
81
+ ' not performed here.\n'
69
82
  );
70
83
  return codeNum;
71
84
  }
@@ -0,0 +1,94 @@
1
+ # Qualifying cuts from a meet-setup file
2
+
3
+ What `qualifyingStandards()` gives you, what it deliberately does not, and the four
4
+ mistakes that are easy to make with it.
5
+
6
+ ## The shape
7
+
8
+ One row per event that states a real cut. This is verbatim output, not an illustration:
9
+
10
+ ```json
11
+ {
12
+ "eventNumber": "55",
13
+ "description": "Girls 13-14 50m Freestyle",
14
+ "gender": "F",
15
+ "ageGroup": { "label": "13-14", "lower": 13, "upper": 14 },
16
+ "distance": 50,
17
+ "stroke": "Freestyle",
18
+ "LCM": { "text": "29.49", "seconds": 29.49 },
19
+ "SCM": { "text": "28.89", "seconds": 28.89 },
20
+ "SCY": { "text": "25.89", "seconds": 25.89 }
21
+ }
22
+ ```
23
+
24
+ `LCM` / `SCM` / `SCY` are the same standard stated three ways — one per course — each a
25
+ `SwimTime` (`{ text, seconds }`) or `null`. Every other field describes the event and
26
+ matches what the result adapters emit for the same event, so a cut row joins to a parsed
27
+ result on `description`, or on `eventNumber` within one meet.
28
+
29
+ ```js
30
+ import { parseSetup, qualifyingStandards } from 'swimparse';
31
+
32
+ const setup = parseSetup(readFileSync('Meet Events-2026 Champs.ev3', 'latin1'));
33
+ const cuts = qualifyingStandards(setup);
34
+ ```
35
+
36
+ Or from the CLI — `swimparse events.ev3 --cuts`, which is the same array. To publish it
37
+ as a table:
38
+
39
+ ```bash
40
+ swimparse events.ev3 --cuts \
41
+ | jq -r '["Event","LCM","SCM","SCY"], (.[] | [.description, .LCM.text, .SCM.text, .SCY.text]) | @csv'
42
+ ```
43
+
44
+ ## Four things to get right
45
+
46
+ **1. Read by course key, never by column position.** The `.ev3` and `.hyv` of the same
47
+ meet order their three time columns differently — the `.hyv` rotates them to start at
48
+ the meet's own course. swimparse resolves that for you; downstream code that
49
+ re-flattens these rows back into a fixed 3-column layout must carry the course label
50
+ with each value or it will re-introduce the bug.
51
+
52
+ **2. `null` means the meet stated no cut in that course.** It does not mean "any time
53
+ qualifies", and it is not an invitation to convert a time from another course.
54
+ **swimparse never converts between courses** — a converted time is a rule the meet
55
+ either accepts or does not, and only the meet announcement says which. If a swimmer's
56
+ only time is SCY and the SCY cut is `null`, the honest answer to "do they qualify" is
57
+ *this file cannot tell you*.
58
+
59
+ A course can also be refused loudly rather than left blank: a meet may fill a column it
60
+ does not accept with a placeholder like `0.01`. `qualifyingStandards()` drops those, so
61
+ they arrive as `null` here — but `setup.events[].qualifyingTimes` keeps them verbatim,
62
+ which is where to look if you need to know the difference between "not stated" and
63
+ "stated as unusable".
64
+
65
+ **3. Compare on `seconds`, display `text`.** `seconds` is a float rounded to hundredths;
66
+ `text` is the canonical `M:SS.ss` form. Never string-compare times.
67
+
68
+ **4. Meeting a cut is your rule, not the file's.** swimparse reports the standard; it
69
+ has no opinion on whether a swimmer meets it. Whether an equal time qualifies, whether a
70
+ bonus or unqualified entry is allowed, how many events a swimmer may enter, and whether
71
+ the swim happened inside the eligible period are all meet rules that live in the meet
72
+ announcement. `meet.qualifyingSince` is the file's own start-of-period date when it
73
+ states one — it is inferred from sample files rather than from a published spec, so
74
+ treat it as a hint to check against the announcement, not as authority.
75
+
76
+ ```js
77
+ // The comparison itself is one line; the judgement around it is yours.
78
+ const qualifies = (swimSeconds, cut) => cut != null && swimSeconds <= cut.seconds;
79
+ ```
80
+
81
+ ## What is absent, and why
82
+
83
+ Relays usually carry no cut at all, so they usually do not appear in this table. Events
84
+ a meet deliberately leaves open — bonus or unqualified events — do not appear either,
85
+ and are indistinguishable here from events the meet forgot to configure. If you need
86
+ every event whether cut or not, iterate `setup.events` instead and read
87
+ `qualifyingTimes` yourself; `qualifyingStandards()` is the convenience view, not the
88
+ whole file.
89
+
90
+ ## Stability
91
+
92
+ The field names above are the contract. Additions are possible; renames or a change of
93
+ meaning are not, without a major version. Two rules will not change:
94
+ `qualifyingStandards()` stays filtered and `events[].qualifyingTimes` stays verbatim.
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
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.",
3
+ "version": "0.2.0",
4
+ "description": "Parses SDIF v3 (.sd3) and Hy-Tek (.hy3) swim-meet result files into one NormalizedMeet JSON contract, and Hy-Tek meet-setup files (.ev3/.hyv) — events, sessions and qualifying cuts — into a NormalizedMeetSetup. Zero dependencies; runs in the browser, Node, and CI.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
9
  "./sdif": "./src/sdif.js",
10
- "./hy3": "./src/hy3.js"
10
+ "./hy3": "./src/hy3.js",
11
+ "./setup": "./src/setup.js"
11
12
  },
12
13
  "bin": {
13
14
  "swimparse": "cli.js"
@@ -15,7 +16,8 @@
15
16
  "files": [
16
17
  "src/",
17
18
  "cli.js",
18
- "README.md"
19
+ "README.md",
20
+ "docs/"
19
21
  ],
20
22
  "scripts": {
21
23
  "test": "node --test"
@@ -31,7 +33,11 @@
31
33
  "sdif",
32
34
  "sd3",
33
35
  "hy3",
36
+ "ev3",
37
+ "hyv",
34
38
  "hy-tek",
39
+ "meet-events",
40
+ "time-standards",
35
41
  "meet-results",
36
42
  "parser",
37
43
  "swim-meet"
package/src/detect.js CHANGED
@@ -4,9 +4,12 @@
4
4
  */
5
5
 
6
6
  /**
7
+ * `ev3` and `hyv` are meet SETUP files (events, no results) — parse those with
8
+ * parseSetup(), not parse().
9
+ *
7
10
  * @param {string} content
8
11
  * @param {string} [filename] optional, used only as a tie-breaker
9
- * @returns {'sdif-v3'|'hy3'|null}
12
+ * @returns {'sdif-v3'|'hy3'|'ev3'|'hyv'|null}
10
13
  */
11
14
  export function detectFormat(content, filename) {
12
15
  const firstLines = String(content).split(/\r?\n/, 5);
@@ -20,10 +23,19 @@ export function detectFormat(content, filename) {
20
23
  if (firstLines.some((l) => l.startsWith('B11') || l.startsWith('D0') || l.startsWith('D3'))) return 'sdif-v3';
21
24
  if (firstLines.some((l) => l.startsWith('D1') || l.startsWith('E1'))) return 'hy3';
22
25
 
26
+ // Meet-setup exports are semicolon-delimited rather than fixed-width. The
27
+ // ev3 flavour terminates every record with `*>`; the hyv flavour does not.
28
+ // Checked after the record-code sniffs above so a fixed-width SDIF .ev3
29
+ // still routes to the SDIF adapter.
30
+ const delimited = firstLines.filter((l) => l.split(';').length >= 10);
31
+ if (delimited.length >= 2) return delimited.some((l) => l.trimEnd().endsWith('*>')) ? 'ev3' : 'hyv';
32
+
23
33
  if (filename) {
24
34
  const ext = filename.toLowerCase().split('.').pop();
25
35
  if (ext === 'hy3') return 'hy3';
26
36
  if (ext === 'sd3' || ext === 'cl2' || ext === 'txt') return 'sdif-v3';
37
+ if (ext === 'ev3') return 'ev3';
38
+ if (ext === 'hyv') return 'hyv';
27
39
  }
28
40
  return null;
29
41
  }
package/src/index.js CHANGED
@@ -1,32 +1,40 @@
1
1
  /**
2
2
  * swimparse — public entry point.
3
3
  *
4
- * Parse SDIF v3 (.sd3) or Hy-Tek (.hy3) meet results into one NormalizedMeet.
4
+ * Parse SDIF v3 (.sd3) or Hy-Tek (.hy3) meet results into one NormalizedMeet,
5
+ * and Hy-Tek meet-setup files (.ev3/.hyv) into one NormalizedMeetSetup.
5
6
  * Zero dependencies; runs in the browser, Node, and CI.
6
7
  *
7
- * import { parse, detectFormat } from 'swimparse';
8
- * const meet = parse(fileText, { filename: 'GG_at_WW.hy3' });
8
+ * import { parse, parseSetup, detectFormat } from 'swimparse';
9
+ * const meet = parse(fileText, { filename: 'GG_at_WW.hy3' });
10
+ * const setup = parseSetup(eventsText, { filename: 'MeetEvents.ev3' });
9
11
  *
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.
12
+ * SCOPE: this library reads meet files and emits JSON. That is all it does. It
13
+ * has no concept of a league — no age bands, no scoring rules, no team registry,
14
+ * and no opinion about whether a given swimmer meets a cut. A setup file states
15
+ * its own qualifying times and those are read like any other field, but applying
16
+ * them is league policy and belongs to the application consuming this output.
14
17
  *
15
- * PRIVACY: the output is a lossless superset of the source file, so it CARRIES
18
+ * PRIVACY: a parsed RESULT file is a lossless superset of its source, so it CARRIES
16
19
  * SWIMMER BIRTHDATES (`swimmers[].birthDate`, individual `results[].birthDate`)
17
20
  * and USA-S registration ids. For youth meets that is PII for minors — treat
18
21
  * every parse result as confidential until your application has stripped or
19
22
  * aggregated it. swimparse deliberately does not do that for you: what counts as
20
23
  * safe is a league decision (a summer league publishes age-group labels; a
21
24
  * USA-Swimming tool needs exact ages), so it belongs to the consumer.
25
+ *
26
+ * Meet-SETUP files (.ev3/.hyv) are the exception: they describe a meet nobody has
27
+ * entered yet, so a NormalizedMeetSetup contains no personal data at all.
22
28
  */
23
29
 
24
30
  import { parseSdif } from './sdif.js';
25
31
  import { parseHy3 } from './hy3.js';
32
+ import { parseEv3, parseHyv } from './setup.js';
26
33
  import { detectFormat } from './detect.js';
27
34
 
28
35
  export { parseSdif } from './sdif.js';
29
36
  export { parseHy3 } from './hy3.js';
37
+ export { parseEv3, parseHyv, qualifyingStandards } from './setup.js';
30
38
  export { detectFormat } from './detect.js';
31
39
  export * from './model.js';
32
40
  export * from './constants.js';
@@ -45,5 +53,28 @@ export function parse(content, opts = {}) {
45
53
  const format = opts.format || detectFormat(content, opts.filename);
46
54
  if (format === 'sdif-v3') return parseSdif(content);
47
55
  if (format === 'hy3') return parseHy3(content);
56
+ if (format === 'ev3' || format === 'hyv') {
57
+ throw new Error(`swimparse: this is a Hy-Tek meet SETUP file (.${format}) — it holds events, not results. Use parseSetup().`);
58
+ }
48
59
  throw new Error('swimparse: could not detect meet-result format (expected SDIF .sd3 or Hy-Tek .hy3)');
49
60
  }
61
+
62
+ /**
63
+ * Parses a Hy-Tek meet-setup file — the event list, sessions and qualifying
64
+ * cuts a meet is built from, before anyone has entered it.
65
+ *
66
+ * @param {string} content
67
+ * @param {Object} [opts]
68
+ * @param {'ev3'|'hyv'} [opts.format] force a format, skipping detection
69
+ * @param {string} [opts.filename] used as a detection tie-breaker
70
+ * @returns {import('./model.js').NormalizedMeetSetup}
71
+ */
72
+ export function parseSetup(content, opts = {}) {
73
+ const format = opts.format || detectFormat(content, opts.filename);
74
+ if (format === 'ev3') return parseEv3(content);
75
+ if (format === 'hyv') return parseHyv(content);
76
+ if (format === 'sdif-v3' || format === 'hy3') {
77
+ throw new Error(`swimparse: this is a meet RESULT file (${format}), not a setup file. Use parse().`);
78
+ }
79
+ throw new Error('swimparse: could not detect meet-setup format (expected Hy-Tek .ev3 or .hyv)');
80
+ }
package/src/model.js CHANGED
@@ -84,6 +84,96 @@
84
84
  * @property {(IndividualResult|RelayResult)[]} results
85
85
  */
86
86
 
87
+ /**
88
+ * The NormalizedMeetSetup contract — what a meet-SETUP file (.ev3/.hyv) holds.
89
+ *
90
+ * A setup file is the meet before it has entrants: the event list, the session
91
+ * schedule, the entry fees, and the qualifying cuts. There are no swimmers and
92
+ * no results in it, so it is a different shape from NormalizedMeet rather than
93
+ * an empty one — `events[]` here are event *definitions*, not event results.
94
+ *
95
+ * It carries no personal data at all, which makes it the one swimparse output
96
+ * that is safe to publish as-is.
97
+ *
98
+ * @typedef {Object} NormalizedMeetSetup
99
+ * @property {'ev3'|'hyv'} format
100
+ * @property {Object} source Producing software (header record).
101
+ * @property {string} [source.software]
102
+ * @property {string} [source.version]
103
+ * @property {string|null} [source.createdAt] ISO date the file was exported.
104
+ * @property {SetupMeetInfo} meet
105
+ * @property {SetupSession[]} sessions Empty for hyv, which has no sessions.
106
+ * @property {SetupEvent[]} events
107
+ */
108
+
109
+ /**
110
+ * @typedef {Object} SetupMeetInfo
111
+ * @property {string} name
112
+ * @property {string} rawName
113
+ * @property {string} [hostName] Host or facility as printed.
114
+ * @property {string|null} startDate ISO "YYYY-MM-DD".
115
+ * @property {string|null} endDate
116
+ * @property {string|null} [ageUpDate] The date ages are computed as of.
117
+ * @property {string|null} [course] 'SCY' | 'LCM' | 'SCM'.
118
+ * @property {string} [sanction] LSC sanction number (ev3 only).
119
+ * @property {string|null} [entryDeadline] ISO (ev3 only).
120
+ * @property {string|null} [qualifyingSince] Start of the period a cut may be
121
+ * swum in — INFERRED from the files,
122
+ * not from a spec (ev3 only).
123
+ * @property {Object} [location] Address fields (ev3 only).
124
+ */
125
+
126
+ /**
127
+ * @typedef {Object} SetupSession
128
+ * @property {string} id As numbered in the file — may be
129
+ * alphanumeric ("1", "2G").
130
+ * @property {number|null} day 1-based day of the meet.
131
+ * @property {string|null} startTime 24-hour "HH:MM".
132
+ * @property {number} eventCount
133
+ */
134
+
135
+ /**
136
+ * @typedef {Object} SetupEvent
137
+ * @property {string} number As printed, letter and all ("1A").
138
+ * @property {'individual'|'relay'} type
139
+ * @property {'finals'|'prelims'|null} round 'prelims' = prelims feeding a final.
140
+ * @property {number|null} rounds 1 = timed finals, 2 = prelims+finals
141
+ * (ev3 only).
142
+ * @property {'M'|'F'|'X'} gender
143
+ * @property {number|null} distance
144
+ * @property {string} stroke Canonical stroke name.
145
+ * @property {string|null} course The event's own course as stated in
146
+ * the file — 'SCY' | 'LCM' | 'SCM'.
147
+ * ev3 only; the hyv states none.
148
+ * @property {{label:string, lower:number, upper:number}} ageGroup
149
+ * @property {string} description Human label, same form the result
150
+ * adapters produce.
151
+ * @property {number|null} relayLegs Legs per relay (ev3 only).
152
+ * @property {number|null} entryFee As stored, in the meet's currency.
153
+ * @property {{LCM: SwimTime|null, SCM: SwimTime|null, SCY: SwimTime|null}} qualifyingTimes
154
+ * The event's cut in each course — the
155
+ * same standard, stated three ways. All
156
+ * null when the meet sets no cuts. Kept
157
+ * verbatim, including the placeholder a
158
+ * meet may fill an unaccepted course
159
+ * with; qualifyingStandards() drops
160
+ * those.
161
+ * @property {{id:string, day:number|null, order:number|null, startTime:string|null}|null} session
162
+ */
163
+
164
+ /**
165
+ * @typedef {Object} QualifyingStandard
166
+ * @property {string} eventNumber
167
+ * @property {string} description
168
+ * @property {'M'|'F'|'X'} gender
169
+ * @property {{label:string, lower:number, upper:number}} ageGroup
170
+ * @property {number|null} distance
171
+ * @property {string} stroke
172
+ * @property {SwimTime|null} LCM
173
+ * @property {SwimTime|null} SCM
174
+ * @property {SwimTime|null} SCY
175
+ */
176
+
87
177
  /**
88
178
  * @typedef {Object} IndividualResult
89
179
  * @property {'individual'} kind
package/src/setup.js ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Hy-Tek meet-SETUP adapter (.ev3 / .hyv) → NormalizedMeetSetup.
3
+ *
4
+ * A setup file is the meet's event list before anyone has entered it: no
5
+ * swimmers, no entries, no results. Meet Manager exports the pair together in
6
+ * one zip — `.ev3` (the richer one: sessions, day, event order, start times)
7
+ * and `.hyv` (the Team Manager import file: the same events, fewer columns).
8
+ *
9
+ * Neither is fixed-width. Both are **semicolon-delimited**, CP-1252, CRLF: one
10
+ * header record, then one record per event. `.ev3` records carry a trailing
11
+ * `*>` terminator, and its header carries a trailing checksum.
12
+ *
13
+ * ev3 1;1A;F;1;I;G;0;8;400;E;0;;;N;12;;;;;;;1;1;1;05:00PM;Y;5;4;1;0*>
14
+ * hyv 1A;F;F;I;0;8;400;5;;;;12;;;;;;
15
+ *
16
+ * NOTE ON THE NAME `.ev3`: SDIF also defines a meet-events file with that
17
+ * extension, fixed-width like `.sd3`. That is a different format; detect.js
18
+ * sniffs content, so a fixed-width `.ev3` still routes to the SDIF adapter.
19
+ *
20
+ * The three qualifying-time columns are the SAME cut in the three courses,
21
+ * verified column-for-column against Virginia Swimming's published 2025-2028
22
+ * Age Group Championship QT table (LCM / SCM / SCY), whose rows the fixture
23
+ * meets reproduce exactly. The two files order those columns differently:
24
+ *
25
+ * ev3 col 16 = LCM, col 18 = SCM, col 20 = SCY — FIXED, whatever the meet's
26
+ * own course. Confirmed against an SCY meet and an LCM meet.
27
+ * hyv cols 9, 13, 15 — ROTATED to start at the meet's own course, then
28
+ * cycling Y → L → S. An SCY meet reads (SCY, LCM, SCM); an LCM meet
29
+ * reads (LCM, SCM, SCY). Both confirmed; the SCM rotation (SCM, SCY,
30
+ * LCM) follows the same cycle but has not been seen in a real file.
31
+ *
32
+ * A course column a meet does not accept can be BLANKET-FILLED with a
33
+ * placeholder rather than left empty: the Eastern Zone fixture carries 0.01 or
34
+ * 1.00 in its SCM column on all 108 events, relays included. Those are stated
35
+ * times, so `qualifyingTimes` keeps them verbatim — dropping file data is not
36
+ * this layer's call — but `qualifyingStandards()` filters them, since a table
37
+ * of cuts is useless with them in. See that function.
38
+ *
39
+ * Each qualifying time is preceded by an always-empty column (ev3 15/17/19,
40
+ * hyv 8/12/14/16), almost certainly the matching "no faster than" limit that
41
+ * Meet Manager pairs with every cut. Empty in every sample, so its meaning is
42
+ * unconfirmed and it is not emitted.
43
+ *
44
+ * Columns deliberately left unread because the samples could not pin them down:
45
+ * ev3 10, 11, 12, 13 (constant), and the per-session trio 26/27/28.
46
+ */
47
+
48
+ import { HY3_STROKE, SDIF_STROKE, STROKE, GENDER_DISPLAY, COURSE, ageGroup } from './constants.js';
49
+ import { timeFromText, normalizeDate } from './times.js';
50
+
51
+ /** Event-sex code → canonical gender. ev3 uses G/B, hyv uses F/M. */
52
+ const EVENT_SEX = { G: 'F', B: 'M', F: 'F', M: 'M', X: 'X' };
53
+
54
+ /** Round code → canonical round. 'P' means prelims feeding a final. */
55
+ const ROUND = { F: 'finals', P: 'prelims' };
56
+
57
+ /** A relay's stroke code means the relay stroke, not the individual one. */
58
+ const RELAY_STROKE_EV3 = { A: STROKE.FREESTYLE, E: STROKE.MEDLEY };
59
+ const RELAY_STROKE_HYV = { 1: STROKE.FREESTYLE, 5: STROKE.MEDLEY };
60
+
61
+ const clean = (s) => (s == null ? '' : String(s).trim());
62
+
63
+ /** "MM/DD/YYYY" → ISO "YYYY-MM-DD". */
64
+ const isoDate = (raw) => normalizeDate(clean(raw).replace(/\//g, ''));
65
+
66
+ /** "05:00PM" → "17:00". Returns null for anything else. */
67
+ function clockTime(raw) {
68
+ const m = /^(\d{1,2}):(\d{2})\s*([AP])M$/i.exec(clean(raw));
69
+ if (!m) return null;
70
+ let hour = parseInt(m[1], 10) % 12;
71
+ if (m[3].toUpperCase() === 'P') hour += 12;
72
+ return `${String(hour).padStart(2, '0')}:${m[2]}`;
73
+ }
74
+
75
+ const int = (raw) => {
76
+ const v = parseInt(clean(raw), 10);
77
+ return Number.isNaN(v) ? null : v;
78
+ };
79
+
80
+ const num = (raw) => {
81
+ const v = parseFloat(clean(raw));
82
+ return Number.isNaN(v) ? null : v;
83
+ };
84
+
85
+ /** Meet Manager writes an unset date as the Unix epoch. */
86
+ const EPOCH_SENTINEL = /^01\/01\/1970$/;
87
+
88
+ /**
89
+ * Below this, a "qualifying time" is a placeholder for a course the meet does
90
+ * not accept, not a standard: no swim of any distance is a second long, and the
91
+ * Eastern Zone fixture blanket-fills its SCM column with 0.01/1.00 on nearly
92
+ * every event, relays included.
93
+ */
94
+ const PLACEHOLDER_CUT_SECONDS = 1;
95
+
96
+ /** Splits a record, dropping the ev3 `*>` terminator from the last field. */
97
+ const fields = (line) => line.replace(/\*>\s*$/, '').split(';');
98
+
99
+ const records = (content) =>
100
+ String(content)
101
+ .split(/\r?\n/)
102
+ .filter((l) => l.trim() !== '')
103
+ .map(fields);
104
+
105
+ /**
106
+ * Builds the shared event shape from already-decoded parts.
107
+ * `description` matches the result adapters', so an event parsed from a setup
108
+ * file and the same event parsed from a result file read identically.
109
+ */
110
+ function buildEvent({ number, type, round, rounds, gender, distance, stroke, course, lower, upper, entryFee, qualifyingTimes, relayLegs, session }) {
111
+ const ag = ageGroup(lower, upper);
112
+ const agLabel = type === 'relay' && ag.label === 'Open' ? '' : ag.label;
113
+ const description = `${GENDER_DISPLAY[gender]} ${agLabel} ${distance}m ${stroke}${type === 'relay' ? ' Relay' : ''}`
114
+ .replace(/\s+/g, ' ')
115
+ .trim();
116
+ return {
117
+ number,
118
+ type,
119
+ round,
120
+ rounds,
121
+ gender,
122
+ distance,
123
+ stroke,
124
+ course,
125
+ ageGroup: ag,
126
+ description,
127
+ relayLegs,
128
+ entryFee,
129
+ qualifyingTimes,
130
+ session,
131
+ };
132
+ }
133
+
134
+ /** { LCM, SCM, SCY } from three raw time strings, any of which may be blank. */
135
+ const qualTimes = (lcm, scm, scy) => ({
136
+ LCM: timeFromText(lcm),
137
+ SCM: timeFromText(scm),
138
+ SCY: timeFromText(scy),
139
+ });
140
+
141
+ /** The cycle the hyv rotates its qualifying-time columns through. */
142
+ const COURSE_CYCLE = ['Y', 'L', 'S'];
143
+
144
+ /**
145
+ * Reads the hyv's three qualifying-time columns, which start at the meet's own
146
+ * course and cycle from there — see the header note.
147
+ * @param {string} courseCode single-char meet course from the hyv header
148
+ * @param {string[]} f the record's fields
149
+ */
150
+ function hyvQualTimes(courseCode, f) {
151
+ const start = Math.max(0, COURSE_CYCLE.indexOf(courseCode));
152
+ const times = { LCM: null, SCM: null, SCY: null };
153
+ [9, 13, 15].forEach((col, i) => {
154
+ times[COURSE[COURSE_CYCLE[(start + i) % COURSE_CYCLE.length]]] = timeFromText(f[col]);
155
+ });
156
+ return times;
157
+ }
158
+
159
+ /** Collapses the per-event session stamps into the meet's session list. */
160
+ function deriveSessions(events) {
161
+ const byId = new Map();
162
+ for (const ev of events) {
163
+ const s = ev.session;
164
+ if (!s) continue;
165
+ if (!byId.has(s.id)) byId.set(s.id, { id: s.id, day: s.day, startTime: s.startTime, eventCount: 0 });
166
+ byId.get(s.id).eventCount += 1;
167
+ }
168
+ return [...byId.values()];
169
+ }
170
+
171
+ /**
172
+ * Flattens a setup's qualifying cuts into one row per event that has one.
173
+ *
174
+ * The cuts are already on `setup.events[].qualifyingTimes`; this is the shape
175
+ * you want when the cuts *are* the thing you came for — a standards table to
176
+ * publish, diff against last season's, or check entries against. Events with no
177
+ * cut configured (the relays, in most meets) are left out.
178
+ *
179
+ * THE ONE PLACE THIS LAYER JUDGES THE DATA: a placeholder time in a course the
180
+ * meet does not accept is dropped here (see PLACEHOLDER_CUT_SECONDS), and a row
181
+ * left with nothing real goes with it. `event.qualifyingTimes` still carries
182
+ * every value the file stated — read that instead if you want the file verbatim.
183
+ *
184
+ * Beyond that, reading the file is all that happens: no conversion between
185
+ * courses, no "does this swimmer qualify" — that is the consumer's call.
186
+ *
187
+ * @param {NormalizedMeetSetup} setup
188
+ * @returns {QualifyingStandard[]}
189
+ */
190
+ export function qualifyingStandards(setup) {
191
+ const real = (t) => (t && t.seconds > PLACEHOLDER_CUT_SECONDS ? t : null);
192
+ return (setup.events || [])
193
+ .map((ev) => ({
194
+ eventNumber: ev.number,
195
+ description: ev.description,
196
+ gender: ev.gender,
197
+ ageGroup: ev.ageGroup,
198
+ distance: ev.distance,
199
+ stroke: ev.stroke,
200
+ LCM: real(ev.qualifyingTimes && ev.qualifyingTimes.LCM),
201
+ SCM: real(ev.qualifyingTimes && ev.qualifyingTimes.SCM),
202
+ SCY: real(ev.qualifyingTimes && ev.qualifyingTimes.SCY),
203
+ }))
204
+ .filter((row) => row.LCM || row.SCM || row.SCY);
205
+ }
206
+
207
+ /**
208
+ * Parses a Meet Manager `.ev3` meet-events export.
209
+ * @param {string} content
210
+ * @returns {import('./model.js').NormalizedMeetSetup}
211
+ */
212
+ export function parseEv3(content) {
213
+ const [head, ...rows] = records(content);
214
+ if (!head) throw new Error('swimparse: empty .ev3 file');
215
+
216
+ const name = clean(head[0]);
217
+ const meet = {
218
+ name,
219
+ rawName: name,
220
+ hostName: clean(head[1]) || undefined,
221
+ startDate: isoDate(head[2]),
222
+ endDate: isoDate(head[3]),
223
+ ageUpDate: isoDate(head[4]),
224
+ course: COURSE[clean(head[5]).charAt(0)] || null,
225
+ sanction: clean(head[14]) || undefined,
226
+ entryDeadline: isoDate(head[23]),
227
+ // INFERRED, not confirmed against a spec: header field 16 reads
228
+ // 11/01/2024 in both Virginia championships (whose standards are the
229
+ // published 2025-2028 set), 08/06/2025 in the Eastern Zone meet (just
230
+ // after the 2025 zone championships), and the epoch sentinel in the one
231
+ // meet that sets no cuts at all. That is what a qualifying-period start
232
+ // looks like. Treated as unset when it reads as the epoch.
233
+ qualifyingSince: EPOCH_SENTINEL.test(clean(head[16])) ? null : isoDate(head[16]),
234
+ location: {
235
+ address: clean(head[24]) || undefined,
236
+ city: clean(head[26]) || undefined,
237
+ state: clean(head[27]) || undefined,
238
+ postalCode: clean(head[28]) || undefined,
239
+ country: clean(head[29]) || undefined,
240
+ lsc: clean(head[30]) || undefined,
241
+ },
242
+ };
243
+
244
+ const events = rows.map((f) => {
245
+ const type = clean(f[4]) === 'R' ? 'relay' : 'individual';
246
+ const strokeCode = clean(f[9]);
247
+ const stroke = (type === 'relay' && RELAY_STROKE_EV3[strokeCode]) || HY3_STROKE[strokeCode] || `Stroke ${strokeCode}`;
248
+ return buildEvent({
249
+ number: clean(f[1]) || clean(f[0]),
250
+ type,
251
+ round: ROUND[clean(f[2])] || null,
252
+ rounds: int(f[3]),
253
+ gender: EVENT_SEX[clean(f[5])] || 'X',
254
+ distance: int(f[8]),
255
+ stroke,
256
+ lower: clean(f[6]),
257
+ upper: clean(f[7]),
258
+ course: COURSE[clean(f[25])] || null,
259
+ entryFee: num(f[14]),
260
+ qualifyingTimes: qualTimes(f[16], f[18], f[20]),
261
+ relayLegs: type === 'relay' ? int(f[29]) : null,
262
+ session: {
263
+ id: clean(f[21]),
264
+ day: int(f[23]),
265
+ order: int(f[22]),
266
+ startTime: clockTime(f[24]),
267
+ },
268
+ });
269
+ });
270
+
271
+ return {
272
+ format: 'ev3',
273
+ source: {
274
+ software: clean(head[9]) || undefined,
275
+ version: clean(head[11]) || undefined,
276
+ createdAt: isoDate(head[12]),
277
+ },
278
+ meet,
279
+ sessions: deriveSessions(events),
280
+ events,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Parses a Meet Manager `.hyv` meet-events export (the Team Manager import
286
+ * file). Same events as the `.ev3`, without sessions, days or start times.
287
+ * @param {string} content
288
+ * @returns {import('./model.js').NormalizedMeetSetup}
289
+ */
290
+ export function parseHyv(content) {
291
+ const [head, ...rows] = records(content);
292
+ if (!head) throw new Error('swimparse: empty .hyv file');
293
+
294
+ const name = clean(head[0]);
295
+ const courseCode = clean(head[4]).charAt(0);
296
+ const meet = {
297
+ name,
298
+ rawName: name,
299
+ hostName: clean(head[5]) || undefined,
300
+ startDate: isoDate(head[1]),
301
+ endDate: isoDate(head[2]),
302
+ ageUpDate: isoDate(head[3]),
303
+ course: COURSE[courseCode] || null,
304
+ };
305
+
306
+ const events = rows.map((f) => {
307
+ const type = clean(f[3]) === 'R' ? 'relay' : 'individual';
308
+ const strokeCode = clean(f[7]);
309
+ const stroke = (type === 'relay' && RELAY_STROKE_HYV[strokeCode]) || SDIF_STROKE[strokeCode] || `Stroke ${strokeCode}`;
310
+ // hyv writes an open-ended upper age as 0; ev3 writes 109.
311
+ const upper = clean(f[5]) === '0' ? '109' : clean(f[5]);
312
+ return buildEvent({
313
+ number: clean(f[0]),
314
+ type,
315
+ round: ROUND[clean(f[1])] || null,
316
+ rounds: null,
317
+ gender: EVENT_SEX[clean(f[2])] || 'X',
318
+ distance: int(f[6]),
319
+ stroke,
320
+ lower: clean(f[4]),
321
+ upper,
322
+ course: null, // the hyv states no per-event course
323
+ entryFee: num(f[11]),
324
+ qualifyingTimes: hyvQualTimes(courseCode, f),
325
+ relayLegs: null,
326
+ session: null,
327
+ });
328
+ });
329
+
330
+ return {
331
+ format: 'hyv',
332
+ source: {
333
+ software: clean(head[7]) || undefined,
334
+ version: clean(head[8]) || undefined,
335
+ createdAt: null,
336
+ },
337
+ meet,
338
+ sessions: [],
339
+ events,
340
+ };
341
+ }