@finbheara/domain 0.5.0 → 0.6.2

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
@@ -39,7 +39,7 @@ ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT. Not affiliated with or endorsed by any d
39
39
  | `@finbheara/domain/tune` | `tune()`, `tuneRelation`, `tempoLegal`, `settingAt`, `TUNE_NODES`: the CLRG set dance list, each tune with its time, speeds and bars |
40
40
  | `@finbheara/domain/ceili` | `ceili()`, `ceiliRelation`, `CEILI_NODES`: the céilí handbook dances |
41
41
  | `@finbheara/domain/dance` | `dance()`: a print read as a tune and a céilí dance, narrowed by its round |
42
- | `@finbheara/domain/age` | `Age.from`, `Age.fromYOB`, `ageRelation`, `canOverlap`, `averageAge`, `isAveraged`, `intersectBirthYears`, `validYOB`, the age constants |
42
+ | `@finbheara/domain/age` | `Age.from`, `Age.fromYOB`, `Age.fromTitle`, `ageTitleToken`, `ageRelation`, `canOverlap`, `averageAge`, `isAveraged`, `intersectBirthYears`, `validYOB`, the age constants |
43
43
  | `@finbheara/domain/noun-index` | `createIndex`, `indexSchema`, `personEncoder`, and the `SqlHandle`/`NounEncoder` types: a database-backed name map (below) |
44
44
  | `@finbheara/domain/dance-types` | `danceTypes()`, `DANCE_TYPES`, `rhythmOfTime`, and the shared `DanceMatch`/`DanceRelation`/`SetRhythm`/`TimeSignature` types |
45
45
  | `@finbheara/domain/promotion` | `samePromotedRow`, `samePromotedSchool`, `samePromotedHeir`, `samePromotedName`, `questionMarksRead`, `foldCharSynonyms`: is a row the same row after a promotion (below) |
@@ -56,7 +56,7 @@ questions:
56
56
  | method | true when |
57
57
  |---|---|
58
58
  | `isEmpty()` | nothing was printed: an empty or blank cell, no dance named |
59
- | `isRefused()` | something was printed and the type declines it: a placeholder, a school in a place field, `U2`, a `1900` year of birth |
59
+ | `isRefused()` | something was printed and the type declines it: a placeholder, a school in a place field, a URL or an online event in a place field, `U2`, a `1900` year of birth |
60
60
  | `isValid()` | neither: `!isEmpty() && !isRefused()` |
61
61
 
62
62
  `invalidReason()` is `null` when the value is valid, `"empty"` when it is empty, and the
@@ -71,6 +71,7 @@ Valid does not mean usable. A print the reader does not know but cannot rule out
71
71
  place("").invalidReason(); // "empty"
72
72
  place("n/a").invalidReason(); // "placeholder"
73
73
  place("Ardmore").isValid(); // true: valid, unknown
74
+ place("Video Feis").invalidReason(); // "online"
74
75
  DEFAULT_SCHOOL_NAME_READER.schoolName("N/A").isRefused(); // true
75
76
  Age.fromYOB(1900).invalidReason(); // "year-out-of-range"
76
77
  tune("").isEmpty(); // true
@@ -102,6 +103,13 @@ A `Place` is one of four arms: `resolved`, `plural` (a print naming more than on
102
103
  `ambiguous` (one print, several readings) and `unread`. Only `conflict` between two readings
103
104
  says they cannot be one place; `same`, `contains` and `overlaps` do not.
104
105
 
106
+ A print that is clearly not a place is refused, with its reason: a placeholder
107
+ (`[address withheld]`, `Other`), a URL anywhere in the print (`web`), an online event in the
108
+ venue field (`online`), a change-of-venue notice (`notice`), or a replacement character where
109
+ the source lost bytes (`damaged`). These are asked only where the store reads nothing, so a
110
+ refusal never takes a reading away. Mojibake that can be decoded back is not refused: repair it
111
+ before reading.
112
+
105
113
  ## Find a CLRG region
106
114
 
107
115
  ```ts
@@ -234,8 +242,39 @@ Age.fromYOB(1999).fitsIn(Age.from("U18", { year: 2025 })); // fals
234
242
  Age.fromYOB(1999).fitsIn(Age.from("Over 21", { year: 2026, top: true })); // true
235
243
 
236
244
  canOverlap(u12, Age.from("U14", { year: 2026 }), dancer); // true
245
+
246
+ Age.from("13 & Over", { year: 2026 }).interval(); // { lo: 13, hi: Infinity }, as "Over 13"
247
+ ```
248
+
249
+ A competition title usually carries its age group in its words. `Age.fromTitle` finds the
250
+ phrase, reads it as `Age.from` would, and says where it was. A title that names no age group
251
+ is empty, not refused. Competition numbers are never read as ages.
252
+
253
+ ```ts
254
+ const t = Age.fromTitle("Novice Hornpipe Under 10", { year: 2026 });
255
+ t.token; // "u10"
256
+ t.span; // { start: 16, end: 24, text: "Under 10" }
257
+ t.age.birthYears(); // { from: 2016, to: 2023 }
258
+
259
+ Age.fromTitle("Open Championship 15-16", { year: 2026 }).token; // "15-16"
260
+ Age.fromTitle("Competition 719OC - Open Championship (O18)").token; // "o18", not "o9"
261
+ Age.fromTitle("Treble Reel Special", { year: 2026 }).age.isEmpty(); // true
262
+ Age.fromTitle("Mixed Reel 7 & Under").token; // "u8": a 7-year-old dances in it
263
+ Age.fromTitle("Girls 8 and under 9 years").token; // "8-9"
264
+ Age.fromTitle("Junior Boys 12 Years").token; // "u13"
265
+ Age.fromTitle("10-11 Single Jig").token; // "10-11", a band, not a number
266
+ Age.fromTitle("Competition 001-013 - Reel").token; // "", a competition number
267
+ Age.fromTitle("Senior Boys Reel 15&16").token; // "15-16": a range beats the word
268
+ Age.fromTitle("Beginner Adults Reel U40").token; // "adult": a split of the adult band
269
+ Age.fromTitle("Adult / Child Reel Under 10").token; // "u10": no adult dances in it
237
270
  ```
238
271
 
272
+ An adult word beside an `Under N` at 18 or above reads `adult`: Adult is a band with an 18
273
+ floor, and the `Under 40` splits it. Below 18 the youth band stands.
274
+
275
+ An age group is the age on the first of January, so a title that admits the dancer who is
276
+ `N` (`7 & Under`, `7/U`, `7 Years`) is the `Under N+1` group.
277
+
239
278
  A value the reader cannot stand behind is returned, not thrown. An `Age` is `Validated` (above):
240
279
  an age group under `U3` admits nobody old enough to dance, and a year of birth outside 1901 to
241
280
  2099 is not one, so the placeholders `1000` and `1900` are refused. An invalid `Age` has no
package/dist/age.js CHANGED
@@ -2,6 +2,111 @@ import {
2
2
  validity
3
3
  } from "./chunks/place-q9kwsmzd.js";
4
4
 
5
+ var LEAD_ID = /^\s*(?:comp(?:etition)?\.?\s*)?\d+[a-z]*(?:[/-]\d*[a-z]*)*\s*[-:.]?\s*/i;
6
+ var PAREN = /\(([^)]*)\)/g;
7
+ var AND_OVER = /(?<![a-z0-9])(\d{1,2})\s*(?:years?\s*)?(?:&|\+|\band\b)\s*(?:over|older|up|o)(?![a-z])/i;
8
+ var OVER_WORD = /(?:\bover\s*|\bos\s+cionn\s*|(?<![a-z0-9])o\.?\s*)(\d{1,2})(?![a-z0-9])/i;
9
+ var OVER_GLUED = /(?<![a-z0-9])(\d{1,2})\s*o(?![a-z'’])/i;
10
+ var OVER_PLUS = /(?<![a-z0-9])(\d{1,2})\s*\+/;
11
+ var UNDER = /(?:\bunder\s*|\bfaoi\s*|(?<![a-z0-9])u\.?\s*)(\d{1,2})(?![a-z0-9])/i;
12
+ var AND_UNDER = /(?<![a-z0-9])(\d{1,2})\s*(?:years?\s*)?(?:&|\/|\band\b)\s*u(?:nder)?(?![a-z])(?!\s*\d)/i;
13
+ var AND_UNDER_RANGE = /(?<![a-z0-9])(\d{1,2})\s*(?:&|\band\b)\s*under\s*(\d{1,2})(?![a-z0-9])/i;
14
+ var AND_RANGE = /(?<![a-z0-9])(?<!\b(?:under|und|over|faoi|cionn|u|o)\.?\s*)(?<!\d\s*,\s*)(?<!\(\s*)(\d{1,2})\s*(?:&|\band\b)\s*(\d{1,2})(?![a-z0-9])(?!\s*-?\s*hand)(?!\s*(?:&|\+|\/|,|\)|\band\b|\bor\b))/i;
15
+ var YEARS = /(?<![a-z0-9])(?<!\d\s*(?:&|\/|\band\b|[-\u0096–—]|\bto\b)\s*)(\d{1,2})\s*(?:years?|yrs?)(?![a-z0-9])(?!\s*(?:&|\+|\/|\band\b|\bor\b|old))/i;
16
+ var RANGE_DASH = String.raw`(?:\u00c2?[-\u0096–—]|\bto\b)`;
17
+ var RANGE = new RegExp(String.raw`(?<![0-9])(?<![0-9][a-z]*)(?<!(?:^|[^a-z0-9])[a-z])(?:u\s*)?(\d{1,2})\s*${RANGE_DASH}\s*(\d{1,2})(?![a-z0-9])`, "i");
18
+ var LEAD_RANGE = new RegExp(String.raw`^\s*([1-9]\d?)\s*${RANGE_DASH}\s*([1-9]\d?)(?![a-z0-9])(?!\s*[/&])`, "i");
19
+ var ADULT_CODE = /^\s*a(?:\d{1,2}(?:u|o|&o))?\s*$/i;
20
+ var ADULT = /(?<!non[\s-])(?<!-)\b(?:adults?|advanced\s+adults?|masters?)\b/i;
21
+ var SENIOR = /(?<!-)\bseniors?\b/i;
22
+ var TITLE_RANGE_MIN = 3;
23
+ var TITLE_RANGE_MAX = 40;
24
+ var NONE = Object.freeze({ token: "", span: null });
25
+ function at(title, offset, start, end, token) {
26
+ const a = offset + start;
27
+ const b = offset + end;
28
+ return { token, span: { start: a, end: b, text: title.slice(a, b) } };
29
+ }
30
+ function inFence(a, b) {
31
+ const lo = Number(a);
32
+ const hi = Number(b);
33
+ return lo >= TITLE_RANGE_MIN && hi <= TITLE_RANGE_MAX && lo < hi;
34
+ }
35
+ function readAnchored(title, s, offset, aside = false) {
36
+ for (const re of [AND_OVER, OVER_WORD, OVER_GLUED]) {
37
+ const m = re.exec(s);
38
+ if (m?.[1] !== undefined)
39
+ return at(title, offset, m.index, m.index + m[0].length, `o${Number(m[1])}`);
40
+ }
41
+ const u = AND_UNDER_RANGE.exec(s);
42
+ if (u?.[1] !== undefined && u[2] !== undefined && inFence(u[1], u[2]))
43
+ return at(title, offset, u.index, u.index + u[0].length, `${Number(u[1])}-${Number(u[2])}`);
44
+ const j = aside ? null : AND_RANGE.exec(s);
45
+ if (j?.[1] !== undefined && j[2] !== undefined && inFence(j[1], j[2]))
46
+ return at(title, offset, j.index, j.index + j[0].length, `${Number(j[1])}-${Number(j[2])}`);
47
+ const l = LEAD_RANGE.exec(s);
48
+ if (l?.[1] !== undefined && l[2] !== undefined && inFence(l[1], l[2])) {
49
+ const lead = l[0].length - l[0].trimStart().length;
50
+ return at(title, offset, lead, l[0].length, `${Number(l[1])}-${Number(l[2])}`);
51
+ }
52
+ const stripped = s.replace(LEAD_ID, "");
53
+ const shift = s.length - stripped.length;
54
+ const r = RANGE.exec(stripped);
55
+ if (r?.[1] !== undefined && r[2] !== undefined && inFence(r[1], r[2]))
56
+ return at(title, offset + shift, r.index, r.index + r[0].length, `${Number(r[1])}-${Number(r[2])}`);
57
+ const au = AND_UNDER.exec(s);
58
+ if (au?.[1] !== undefined)
59
+ return at(title, offset, au.index, au.index + au[0].length, `u${Number(au[1]) + 1}`);
60
+ const m = UNDER.exec(s);
61
+ if (m?.[1] !== undefined)
62
+ return at(title, offset, m.index, m.index + m[0].length, `u${Number(m[1])}`);
63
+ const y = YEARS.exec(s);
64
+ if (y?.[1] !== undefined)
65
+ return at(title, offset, y.index, y.index + y[0].length, `u${Number(y[1]) + 1}`);
66
+ const p = OVER_PLUS.exec(s);
67
+ if (p?.[1] !== undefined)
68
+ return at(title, offset, p.index, p.index + p[0].length, `o${Number(p[1])}`);
69
+ const a = ADULT.exec(s);
70
+ if (a !== null)
71
+ return at(title, offset, a.index, a.index + a[0].length, "adult");
72
+ const n = SENIOR.exec(s);
73
+ if (n !== null)
74
+ return at(title, offset, n.index, n.index + n[0].length, "senior");
75
+ return NONE;
76
+ }
77
+ function ageTitleToken(title) {
78
+ const s = title.trim();
79
+ if (s === "")
80
+ return NONE;
81
+ const lead = title.length - title.trimStart().length;
82
+ PAREN.lastIndex = 0;
83
+ let adultAside = null;
84
+ for (const m of s.matchAll(PAREN)) {
85
+ const inner = m[1] ?? "";
86
+ const from = lead + (m.index ?? 0) + 1;
87
+ const i = m.index ?? 0;
88
+ const tail = i + m[0].length === s.length && !/[a-z0-9]/i.test(s[i - 1] ?? "");
89
+ if (tail && ADULT_CODE.test(inner)) {
90
+ const a = inner.length - inner.trimStart().length;
91
+ adultAside = at(title, from, a, inner.trimEnd().length, "adult");
92
+ continue;
93
+ }
94
+ const t = readAnchored(title, inner, from, true);
95
+ if (t.token !== "")
96
+ return adultOverUnder(title, s, lead, t);
97
+ }
98
+ const words = readAnchored(title, s, lead);
99
+ return words.token === "" && adultAside !== null ? adultAside : adultOverUnder(title, s, lead, words);
100
+ }
101
+ var ADULT_UNDER_MIN = 18;
102
+ function adultOverUnder(title, s, lead, t) {
103
+ const n = /^u(\d+)$/.exec(t.token)?.[1];
104
+ if (n === undefined || Number(n) < ADULT_UNDER_MIN)
105
+ return t;
106
+ const a = ADULT.exec(s);
107
+ return a === null ? t : at(title, lead, a.index, a.index + a[0].length, "adult");
108
+ }
109
+
5
110
  var YOUNGEST_FLOOR = 0;
6
111
  var ADULT_FLOOR = 18;
7
112
  var MAJORS_FLOOR_YEARS = 3;
@@ -15,9 +120,10 @@ var YOB_RANGE = Object.freeze({ first: 1901, last: 2099 });
15
120
  var DANCE_UP_YEARS = 1;
16
121
  var ageOnJanuary1 = (year, born) => year - born - 1;
17
122
  var yearsSinceBirth = (year, born) => year - born;
18
- var UNDER = /^(?:u|under\s*)(\d+)$/i;
123
+ var UNDER2 = /^(?:u|under\s*)(\d+)$/i;
19
124
  var OVER = /^(?:o|over\s*)(\d+)$/i;
20
- var RANGE = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
125
+ var AND_OVER2 = /^(\d+)\s*(?:&|and\b)\s*(?:over|o)$/i;
126
+ var RANGE2 = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
21
127
  var ADULT_WORD = /^(?:adults?|ad|advanced|masters?)$/i;
22
128
  var SENIOR_WORD = /^seniors?$/i;
23
129
  var NO_LADDER = Object.freeze({ caps: [], overs: [], ranges: [] });
@@ -44,29 +150,36 @@ function readRange(a, b, ladder) {
44
150
  clauses: [],
45
151
  parts: [a, b]
46
152
  };
47
- const overlapping = ladder.ranges.includes(a) || ladder.ranges.includes(b + 1);
48
- const bottom = !ladder.caps.some((c) => c <= a);
49
- const atAdultFloor = bottom && a >= ADULT_FLOOR;
50
- const youngest = bottom && !atAdultFloor;
51
- const onRung = ladder.caps.includes(a);
52
- const floor = overlapping || atAdultFloor || onRung ? a : Math.max(YOUNGEST_FLOOR, a - 1);
53
- const clauses = [overlapping ? "range-overlapping" : "range-inclusive"];
54
- if (youngest)
55
- clauses.push("youngest-absorbs");
56
- else if (atAdultFloor)
57
- clauses.push("range-adult-floor");
58
- else if (!overlapping)
59
- clauses.push(onRung ? "range-floor-on-rung" : "range-floor-widened");
153
+ if (ladder.ranges.some(([c, d]) => (c !== a || d !== b) && c < b && a < d))
154
+ return {
155
+ kind: "range",
156
+ interval: null,
157
+ refusal: "overlapping-ranges",
158
+ clauses: ["range-overlapping"],
159
+ parts: [a, b]
160
+ };
161
+ const below = maxWhere(ladder.caps, (c) => c < b);
162
+ const adultFloor = below === undefined && ladder.adult === true && b > ADULT_FLOOR;
163
+ const meetsOver = ladder.overs.includes(b + 1) && !ladder.caps.some((c) => c > b);
164
+ const clauses = [
165
+ "range-as-under",
166
+ below !== undefined ? "under-floored-on-rung" : adultFloor ? "range-adult-floor" : "youngest-absorbs"
167
+ ];
168
+ if (meetsOver)
169
+ clauses.push("range-top-meets-over");
60
170
  return {
61
171
  kind: "range",
62
- interval: { lo: youngest ? YOUNGEST_FLOOR : floor, hi: overlapping ? b : b + 1 },
172
+ interval: {
173
+ lo: below ?? (adultFloor ? ADULT_FLOOR : YOUNGEST_FLOOR),
174
+ hi: meetsOver ? b + 1 : b
175
+ },
63
176
  refusal: null,
64
177
  clauses,
65
178
  parts: [a, b]
66
179
  };
67
180
  }
68
181
  function read(token, ladder, top) {
69
- const r = RANGE.exec(token);
182
+ const r = RANGE2.exec(token);
70
183
  if (r !== null)
71
184
  return readRange(Number(r[1]), Number(r[2]), ladder);
72
185
  if (ADULT_WORD.test(token))
@@ -87,7 +200,7 @@ function read(token, ladder, top) {
87
200
  parts: []
88
201
  };
89
202
  }
90
- const u = UNDER.exec(token);
203
+ const u = UNDER2.exec(token);
91
204
  if (u !== null) {
92
205
  const n = Number(u[1]);
93
206
  const below = maxWhere(ladder.caps, (c) => c < n);
@@ -99,7 +212,7 @@ function read(token, ladder, top) {
99
212
  parts: [n]
100
213
  };
101
214
  }
102
- const o = OVER.exec(token);
215
+ const o = OVER.exec(token) ?? AND_OVER2.exec(token);
103
216
  if (o !== null) {
104
217
  const n = Number(o[1]);
105
218
  const above = top ? undefined : minWhere(ladder.overs, (c) => c > n);
@@ -194,7 +307,7 @@ function fromPrint(print, context = {}) {
194
307
  throw new Error(`Age.from(${JSON.stringify(print)}): birthYears needs a competition year`);
195
308
  return birthYearsOfInterval(iv, at);
196
309
  },
197
- floorObserved: () => reading.kind !== "under" || iv === null ? null : reading.clauses.includes("under-floored-on-rung")
310
+ floorObserved: () => reading.kind !== "under" && reading.kind !== "range" || iv === null ? null : reading.clauses.includes("under-floored-on-rung") || reading.clauses.includes("range-adult-floor")
198
311
  });
199
312
  }
200
313
  function fromYOB(from, to = from, context = {}) {
@@ -237,21 +350,24 @@ function fromYOB(from, to = from, context = {}) {
237
350
  floorObserved: () => null
238
351
  });
239
352
  }
240
- var Age = Object.freeze({ from: fromPrint, fromYOB });
353
+ function fromTitle(title, context = {}) {
354
+ const t = ageTitleToken(title);
355
+ return Object.freeze({ age: fromPrint(t.token, context), token: t.token, span: t.span });
356
+ }
357
+ var Age = Object.freeze({ from: fromPrint, fromYOB, fromTitle });
241
358
  function sourceOf(r) {
242
359
  const iv = r.interval;
243
360
  if (iv === null)
244
361
  return "open";
245
362
  switch (r.kind) {
246
363
  case "range":
247
- return "printed-range";
364
+ case "under":
365
+ return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
248
366
  case "over":
249
367
  return iv.hi === Number.POSITIVE_INFINITY ? "printed-band" : "sibling-narrowed";
250
368
  case "adult":
251
369
  case "senior":
252
370
  return "printed-band";
253
- case "under":
254
- return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
255
371
  default:
256
372
  return "open";
257
373
  }
@@ -435,6 +551,8 @@ export {
435
551
  MAJORS_FLOOR_YEARS,
436
552
  MIN_FEIS_AGE,
437
553
  MIN_MAJOR_AGE,
554
+ TITLE_RANGE_MAX,
555
+ TITLE_RANGE_MIN,
438
556
  UNDER_CAP,
439
557
  UNDER_CAP_MAX_TOP,
440
558
  YOB_RANGE,
@@ -443,6 +561,7 @@ export {
443
561
  YOUNG_FLOOR,
444
562
  ageOnJanuary1,
445
563
  ageRelation,
564
+ ageTitleToken,
446
565
  averageAge,
447
566
  birthYearsDisjoint,
448
567
  birthYearsMeet,