@finbheara/domain 0.5.0 → 0.7.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
@@ -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,41 @@ 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 u40": the adult band under 40
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` above 18 reads both: `adult u40` is adult (`isAdult()`), with
273
+ the adult floor of 18 and a ceiling of 40, so `Age.from("adult u40", { year: 2026 })` is born
274
+ 1986 to 2007. At `Under 18` the ceiling meets the floor and the word alone is read. Below 18 the
275
+ youth band stands.
276
+
277
+ An age group is the age on the first of January, so a title that admits the dancer who is
278
+ `N` (`7 & Under`, `7/U`, `7 Years`) is the `Under N+1` group.
279
+
239
280
  A value the reader cannot stand behind is returned, not thrown. An `Age` is `Validated` (above):
240
281
  an age group under `U3` admits nobody old enough to dance, and a year of birth outside 1901 to
241
282
  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,112 @@ 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
+ const token = Number(n) > ADULT_UNDER_MIN ? `adult u${n}` : "adult";
108
+ return a === null ? t : at(title, lead, a.index, a.index + a[0].length, token);
109
+ }
110
+
5
111
  var YOUNGEST_FLOOR = 0;
6
112
  var ADULT_FLOOR = 18;
7
113
  var MAJORS_FLOOR_YEARS = 3;
@@ -15,10 +121,12 @@ var YOB_RANGE = Object.freeze({ first: 1901, last: 2099 });
15
121
  var DANCE_UP_YEARS = 1;
16
122
  var ageOnJanuary1 = (year, born) => year - born - 1;
17
123
  var yearsSinceBirth = (year, born) => year - born;
18
- var UNDER = /^(?:u|under\s*)(\d+)$/i;
124
+ var UNDER2 = /^(?:u|under\s*)(\d+)$/i;
19
125
  var OVER = /^(?:o|over\s*)(\d+)$/i;
20
- var RANGE = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
126
+ var AND_OVER2 = /^(\d+)\s*(?:&|and\b)\s*(?:over|o)$/i;
127
+ var RANGE2 = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
21
128
  var ADULT_WORD = /^(?:adults?|ad|advanced|masters?)$/i;
129
+ var ADULT_UNDER = /^(?:adults?|ad)[\s-]*(?:u|under\s*)(\d+)$/i;
22
130
  var SENIOR_WORD = /^seniors?$/i;
23
131
  var NO_LADDER = Object.freeze({ caps: [], overs: [], ranges: [] });
24
132
  function maxWhere(xs, pred) {
@@ -44,32 +152,48 @@ function readRange(a, b, ladder) {
44
152
  clauses: [],
45
153
  parts: [a, b]
46
154
  };
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");
155
+ if (ladder.ranges.some(([c, d]) => (c !== a || d !== b) && c < b && a < d))
156
+ return {
157
+ kind: "range",
158
+ interval: null,
159
+ refusal: "overlapping-ranges",
160
+ clauses: ["range-overlapping"],
161
+ parts: [a, b]
162
+ };
163
+ const below = maxWhere(ladder.caps, (c) => c < b);
164
+ const adultFloor = below === undefined && ladder.adult === true && b > ADULT_FLOOR;
165
+ const meetsOver = ladder.overs.includes(b + 1) && !ladder.caps.some((c) => c > b);
166
+ const clauses = [
167
+ "range-as-under",
168
+ below !== undefined ? "under-floored-on-rung" : adultFloor ? "range-adult-floor" : "youngest-absorbs"
169
+ ];
170
+ if (meetsOver)
171
+ clauses.push("range-top-meets-over");
60
172
  return {
61
173
  kind: "range",
62
- interval: { lo: youngest ? YOUNGEST_FLOOR : floor, hi: overlapping ? b : b + 1 },
174
+ interval: {
175
+ lo: below ?? (adultFloor ? ADULT_FLOOR : YOUNGEST_FLOOR),
176
+ hi: meetsOver ? b + 1 : b
177
+ },
63
178
  refusal: null,
64
179
  clauses,
65
180
  parts: [a, b]
66
181
  };
67
182
  }
68
183
  function read(token, ladder, top) {
69
- const r = RANGE.exec(token);
184
+ const r = RANGE2.exec(token);
70
185
  if (r !== null)
71
186
  return readRange(Number(r[1]), Number(r[2]), ladder);
72
- if (ADULT_WORD.test(token))
187
+ const au = ADULT_UNDER.exec(token);
188
+ if (au !== null && Number(au[1]) > ADULT_FLOOR)
189
+ return {
190
+ kind: "adult",
191
+ interval: { lo: ADULT_FLOOR, hi: Number(au[1]) },
192
+ refusal: null,
193
+ clauses: ["adult-under-ceiling"],
194
+ parts: [Number(au[1])]
195
+ };
196
+ if (au !== null || ADULT_WORD.test(token))
73
197
  return {
74
198
  kind: "adult",
75
199
  interval: { lo: ADULT_FLOOR, hi: Number.POSITIVE_INFINITY },
@@ -87,7 +211,7 @@ function read(token, ladder, top) {
87
211
  parts: []
88
212
  };
89
213
  }
90
- const u = UNDER.exec(token);
214
+ const u = UNDER2.exec(token);
91
215
  if (u !== null) {
92
216
  const n = Number(u[1]);
93
217
  const below = maxWhere(ladder.caps, (c) => c < n);
@@ -99,7 +223,7 @@ function read(token, ladder, top) {
99
223
  parts: [n]
100
224
  };
101
225
  }
102
- const o = OVER.exec(token);
226
+ const o = OVER.exec(token) ?? AND_OVER2.exec(token);
103
227
  if (o !== null) {
104
228
  const n = Number(o[1]);
105
229
  const above = top ? undefined : minWhere(ladder.overs, (c) => c > n);
@@ -194,7 +318,7 @@ function fromPrint(print, context = {}) {
194
318
  throw new Error(`Age.from(${JSON.stringify(print)}): birthYears needs a competition year`);
195
319
  return birthYearsOfInterval(iv, at);
196
320
  },
197
- floorObserved: () => reading.kind !== "under" || iv === null ? null : reading.clauses.includes("under-floored-on-rung")
321
+ floorObserved: () => reading.kind !== "under" && reading.kind !== "range" || iv === null ? null : reading.clauses.includes("under-floored-on-rung") || reading.clauses.includes("range-adult-floor")
198
322
  });
199
323
  }
200
324
  function fromYOB(from, to = from, context = {}) {
@@ -237,21 +361,24 @@ function fromYOB(from, to = from, context = {}) {
237
361
  floorObserved: () => null
238
362
  });
239
363
  }
240
- var Age = Object.freeze({ from: fromPrint, fromYOB });
364
+ function fromTitle(title, context = {}) {
365
+ const t = ageTitleToken(title);
366
+ return Object.freeze({ age: fromPrint(t.token, context), token: t.token, span: t.span });
367
+ }
368
+ var Age = Object.freeze({ from: fromPrint, fromYOB, fromTitle });
241
369
  function sourceOf(r) {
242
370
  const iv = r.interval;
243
371
  if (iv === null)
244
372
  return "open";
245
373
  switch (r.kind) {
246
374
  case "range":
247
- return "printed-range";
375
+ case "under":
376
+ return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
248
377
  case "over":
249
378
  return iv.hi === Number.POSITIVE_INFINITY ? "printed-band" : "sibling-narrowed";
250
379
  case "adult":
251
380
  case "senior":
252
381
  return "printed-band";
253
- case "under":
254
- return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
255
382
  default:
256
383
  return "open";
257
384
  }
@@ -435,6 +562,8 @@ export {
435
562
  MAJORS_FLOOR_YEARS,
436
563
  MIN_FEIS_AGE,
437
564
  MIN_MAJOR_AGE,
565
+ TITLE_RANGE_MAX,
566
+ TITLE_RANGE_MIN,
438
567
  UNDER_CAP,
439
568
  UNDER_CAP_MAX_TOP,
440
569
  YOB_RANGE,
@@ -443,6 +572,7 @@ export {
443
572
  YOUNG_FLOOR,
444
573
  ageOnJanuary1,
445
574
  ageRelation,
575
+ ageTitleToken,
446
576
  averageAge,
447
577
  birthYearsDisjoint,
448
578
  birthYearsMeet,