@fhss-web-team/fuzzy-dates 1.2.3 → 2.0.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.
Files changed (48) hide show
  1. package/README.md +99 -36
  2. package/dist/index.d.ts +1 -3
  3. package/dist/index.js +1 -2
  4. package/dist/src/collation/collationKey.d.ts +2 -0
  5. package/dist/src/collation/collationKey.js +44 -0
  6. package/dist/{fuzzyDate → src}/fuzzyDate.d.ts +85 -69
  7. package/dist/src/fuzzyDate.js +238 -0
  8. package/dist/src/gedcomX/toGedcomX.js +28 -0
  9. package/dist/src/helpers/constants.d.ts +2 -0
  10. package/dist/src/helpers/constants.js +2 -0
  11. package/dist/src/helpers/types.d.ts +12 -0
  12. package/dist/src/helpers/types.js +3 -0
  13. package/dist/src/normalize/normalize.js +51 -0
  14. package/dist/src/parse/index.d.ts +47 -0
  15. package/dist/src/parse/index.js +41 -0
  16. package/dist/src/parse/modifiers.d.ts +182 -0
  17. package/dist/src/parse/modifiers.js +62 -0
  18. package/dist/{fuzzyDate/parse/inputDateFormats.d.ts → src/parse/simpleDate/formats.d.ts} +33 -50
  19. package/dist/{fuzzyDate/parse/inputDateFormats.js → src/parse/simpleDate/formats.js} +69 -88
  20. package/dist/src/parse/simpleDate/helpers.d.ts +21 -0
  21. package/dist/src/parse/simpleDate/helpers.js +58 -0
  22. package/dist/src/parse/simpleDate/parse.d.ts +31 -0
  23. package/dist/{fuzzyDate/parse/stringToDate.js → src/parse/simpleDate/parse.js} +4 -5
  24. package/package.json +1 -11
  25. package/dist/fuzzyDate/collate/collate.d.ts +0 -2
  26. package/dist/fuzzyDate/collate/collate.js +0 -15
  27. package/dist/fuzzyDate/fuzzyDate.js +0 -246
  28. package/dist/fuzzyDate/fuzzyDate.spec.d.ts +0 -1
  29. package/dist/fuzzyDate/fuzzyDate.spec.js +0 -158
  30. package/dist/fuzzyDate/gedcomX/toGedcomX.js +0 -31
  31. package/dist/fuzzyDate/helpers/constants.d.ts +0 -4
  32. package/dist/fuzzyDate/helpers/constants.js +0 -20
  33. package/dist/fuzzyDate/helpers/schemas.d.ts +0 -36
  34. package/dist/fuzzyDate/helpers/schemas.js +0 -12
  35. package/dist/fuzzyDate/helpers/types.d.ts +0 -16
  36. package/dist/fuzzyDate/helpers/types.js +0 -1
  37. package/dist/fuzzyDate/normalize/normalize.js +0 -47
  38. package/dist/fuzzyDate/parse/index.d.ts +0 -178
  39. package/dist/fuzzyDate/parse/index.js +0 -92
  40. package/dist/fuzzyDate/parse/modifiers.d.ts +0 -231
  41. package/dist/fuzzyDate/parse/modifiers.js +0 -185
  42. package/dist/fuzzyDate/parse/stringToDate.d.ts +0 -38
  43. /package/dist/{fuzzyDate → src}/gedcomX/toGedcomX.d.ts +0 -0
  44. /package/dist/{fuzzyDate → src}/helpers/result.d.ts +0 -0
  45. /package/dist/{fuzzyDate → src}/helpers/result.js +0 -0
  46. /package/dist/{fuzzyDate → src}/normalize/normalize.d.ts +0 -0
  47. /package/dist/{fuzzyDate/helpers → src/parse/simpleDate}/maps.d.ts +0 -0
  48. /package/dist/{fuzzyDate/helpers → src/parse/simpleDate}/maps.js +0 -0
package/README.md CHANGED
@@ -1,16 +1,10 @@
1
1
  # Fuzzy Dates
2
2
 
3
- Parse imprecise, human-entered date strings into a structured, consistent model that you can normalize, search, sort, and serialize.
4
-
5
- Explore the docs
6
-
7
- ## Features
8
-
9
- - Handles modifiers like `before`, `after`, `about`, `between`, `from`, `early`, `mid`, `late`.
10
- - Normalizes many input shapes (`1st of February 1900`, `Feb 1 1900`, `winter 1890`, `1800s`).
11
- - Produces inclusive lower/upper bounds for range filtering and a collation key for stable chronological sorting.
12
- - Outputs a canonical JSON model plus GEDCOM X formal dates for genealogy interoperability.
13
- - ESM + TypeScript ready (`.d.ts` shipped with the package).
3
+ Parse human-entered fuzzy date text into a consistent model you can use to:
4
+ - normalize display text,
5
+ - filter with inclusive UTC bounds,
6
+ - sort chronologically with deterministic keys,
7
+ - serialize to GEDCOM X formal dates.
14
8
 
15
9
  ## Installation
16
10
 
@@ -21,43 +15,112 @@ npm i @fhss-web-team/fuzzy-dates
21
15
  ## Quick start
22
16
 
23
17
  ```ts
24
- import { FuzzyDate } from "@fhss-web-team/fuzzy-dates";
18
+ import { FuzzyDate } from '@fhss-web-team/fuzzy-dates';
25
19
 
26
- const parsed = FuzzyDate.parse("about Feb 1900");
27
- if (!parsed.ok) throw parsed.error;
20
+ const parsed = FuzzyDate.parse('about Feb 1900');
21
+ if (!parsed.ok) throw new Error(parsed.error);
28
22
 
29
23
  const date = parsed.value;
30
- console.log(date.normalized); // "about 1 February 1900"
31
- console.log(date.lowerBound); // 1899-02-01T00:00:00.000Z (Date)
32
- console.log(date.upperBound); // 1901-01-31T23:59:59.999Z (Date)
33
- console.log(date.collationKey); // deterministic string for sorting
34
-
35
- // Serialize for storage and hydrate later
36
- const json = date.toJSON();
37
- const hydrated = FuzzyDate.fromJSON(json);
24
+ console.log(date.normalized); // "about February 1900"
25
+ console.log(date.earliest.toISOString()); // "1900-02-01T00:00:00.000Z"
26
+ console.log(date.latest.toISOString()); // "1900-02-28T23:59:59.999Z"
27
+ console.log(date.collationKeys); // [primary, timeRelation, range, approximate]
28
+ console.log(date.formal); // "A+1900-02"
29
+ ```
30
+
31
+ ## API
32
+
33
+ ### `FuzzyDate.parse(input: string): Result<FuzzyDate, string>`
34
+ Non-throwing parse entrypoint.
35
+
36
+ - Success shape: `{ ok: true, value: FuzzyDate }`
37
+ - Error shape: `{ ok: false, error: string }`
38
+
39
+ ### `FuzzyDate.sort(a: FuzzyDate, b: FuzzyDate): number`
40
+ Comparator for chronological ordering.
41
+
42
+ ```ts
43
+ dates.sort(FuzzyDate.sort);
38
44
  ```
39
45
 
40
- ## API snapshot
46
+ ### `FuzzyDate.query(searchStart: Date, searchEnd: Date, dates: readonly FuzzyDate[]): FuzzyDate[]`
47
+ Filters using endpoint-in-window logic (inclusive):
48
+
49
+ ```text
50
+ (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
51
+ ```
41
52
 
42
- - `FuzzyDate.parse(input: string): Result<FuzzyDate, string>` non-throwing parse; errors come back as `{ ok: false, error }`.
43
- - `FuzzyDate.fromJSON(model: FuzzyDateModel)` — rebuild from the canonical JSON model.
44
- - `FuzzyDate` instance properties:
45
- - `normalized` — normalized human-readable string.
46
- - `lowerBound` / `upperBound` — inclusive Date bounds (or `null` for unbounded).
47
- - `collationKey` — sortable string aligned to chronological order.
48
- - `formal` — GEDCOM X formal date representation.
49
- - `toJSON()` returns the canonical model.
53
+ This is intentionally different from generic interval-overlap matching.
54
+
55
+ ### Instance getters
56
+
57
+ - `normalized: string`
58
+ Standardized human-readable expression.
59
+ - `formal: string`
60
+ GEDCOM X formal date representation.
61
+ - `approximate: boolean`
62
+ Whether the parsed expression is marked approximate.
63
+ - `earliest: Date` / `latest: Date`
64
+ Inclusive UTC bounds. Open-ended ranges are represented with sentinel values:
65
+ - `before ...` uses `earliest = new Date(-8640000000000000)`
66
+ - `after ...` uses `latest = new Date(8640000000000000)`
67
+ - `collationKeys: readonly [number, -1 | 0 | 1, number, 0 | 1]`
68
+ Keys used by `FuzzyDate.sort`.
69
+
70
+ ## Supported input
71
+
72
+ ### Modifiers
73
+
74
+ - `about ...`, `approximately ...`, `around ...`
75
+ - `before ...`
76
+ - `after ...`
77
+ - `between ... and ...`
78
+ - `from ... to ...`
79
+
80
+ Notes:
81
+ - `between ... and ...` is treated as approximate.
82
+ - `from ... to ...` is exact unless prefixed with an approximation modifier.
83
+
84
+ ### Simple date forms
85
+
86
+ Supported orderings include:
87
+ - `YYYY`
88
+ - `month YYYY` / `YYYY month` (named month, numeric month, or season)
89
+ - `day month YYYY`
90
+ - `month day YYYY`
91
+ - `YYYY month day`
92
+ - `YYYY day month`
93
+ - digit variants for month/day in those positions
94
+
95
+ Examples:
96
+ - `1900`
97
+ - `January 1900`, `1900 Jan`, `01 1900`, `1900 1`
98
+ - `1 Jan 1900`, `Jan 1 1900`, `1900 January 1`, `1900 1 Jan`
99
+ - `winter 1900`, `1900 autumn`
100
+
101
+ Input normalization steps:
102
+ - case-insensitive,
103
+ - strips ordinal suffixes (`1st`, `2nd`, `3rd`, `4th`, ...),
104
+ - treats punctuation/separators (`. , / - _`) as whitespace.
105
+
106
+ ## Behavior details
107
+
108
+ - All parsing and bounds are UTC-based.
109
+ - Calendar-overflow dates are normalized by JavaScript `Date` semantics.
110
+ - `29 feb 1900` normalizes to `1 March 1900`.
111
+ - `31 apr 1900` normalizes to `1 May 1900`.
112
+ - Current year support is strictly four digits (`0000`-`9999` pattern in input).
113
+ - Decade shorthand like `1800s` is not currently supported.
50
114
 
51
115
  ## Development
52
116
 
53
- - Tests: `npm test` (Vitest)
54
- - Build: `npm run build` (TypeScript -> `dist/`)
55
- - Pack locally (publish dry-run): `npm pack` then install the generated `.tgz` in a temp project to verify exports and typings.
117
+ - Tests: `npm test`
118
+ - Build: `npm run build`
119
+ - Package check: `npm pack`
56
120
 
57
121
  ## Support
58
122
 
59
- This package was developed by the [BYU's Center for Family History and Genealogy](https://cfhg.byu.edu/). To support
60
- our mission to provide quality online family history research and resources for the public at no cost, consider donating [HERE](https://donate.churchofjesuschrist.org/contribute/byu/family-home-social-sciences/center-family-history-genealogy)
123
+ This package was developed by the [BYU Center for Family History and Genealogy](https://cfhg.byu.edu/). To support our mission to provide quality online family history research and resources for the public at no cost, consider donating [here](https://donate.churchofjesuschrist.org/contribute/byu/family-home-social-sciences/center-family-history-genealogy).
61
124
 
62
125
  ## License
63
126
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1 @@
1
- export { FuzzyDate } from './fuzzyDate/fuzzyDate.js';
2
- export { FuzzyDateJson } from './fuzzyDate/helpers/types.js';
3
- export { fuzzyDateJsonSchema } from './fuzzyDate/helpers/schemas.js';
1
+ export { FuzzyDate } from './src/fuzzyDate.js';
package/dist/index.js CHANGED
@@ -1,2 +1 @@
1
- export { FuzzyDate } from './fuzzyDate/fuzzyDate.js';
2
- export { fuzzyDateJsonSchema } from './fuzzyDate/helpers/schemas.js';
1
+ export { FuzzyDate } from './src/fuzzyDate.js';
@@ -0,0 +1,2 @@
1
+ import { FuzzyDateModel } from '../helpers/types';
2
+ export declare function collate(model: FuzzyDateModel): readonly [number, -1 | 0 | 1, number, 0 | 1];
@@ -0,0 +1,44 @@
1
+ import { isRange } from '../helpers/types';
2
+ export function collate(model) {
3
+ if (isRange(model)) {
4
+ // left open
5
+ if (model.start === null && model.end !== null) {
6
+ return [
7
+ model.end.min.getTime(),
8
+ -1,
9
+ -1 * (model.end.max.getTime() - model.end.min.getTime()),
10
+ model.approximate ? 0 : 1,
11
+ ];
12
+ }
13
+ // right open
14
+ if (model.end === null && model.start !== null) {
15
+ return [
16
+ model.start.max.getTime(),
17
+ 1,
18
+ -1 * (model.start.max.getTime() - model.start.min.getTime()),
19
+ model.approximate ? 0 : 1,
20
+ ];
21
+ }
22
+ // closed
23
+ if (model.start !== null && model.end !== null) {
24
+ return [
25
+ model.start.min.getTime(),
26
+ 0,
27
+ -1 * (model.end.max.getTime() - model.start.min.getTime()),
28
+ model.approximate ? 0 : 1,
29
+ ];
30
+ }
31
+ }
32
+ else {
33
+ // simple
34
+ if (model.start !== null && model.end !== null) {
35
+ return [
36
+ model.start.min.getTime(),
37
+ 0,
38
+ -1 * (model.end.max.getTime() - model.start.min.getTime()),
39
+ model.approximate ? 0 : 1,
40
+ ];
41
+ }
42
+ }
43
+ return [Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER, 1];
44
+ }
@@ -1,4 +1,3 @@
1
- import { FuzzyDateJson } from './helpers/types';
2
1
  /**
3
2
  * Represents an immutable, parsed fuzzy date suitable for genealogy and
4
3
  * historical data contexts.
@@ -19,9 +18,9 @@ import { FuzzyDateJson } from './helpers/types';
19
18
  * ### Design principles
20
19
  *
21
20
  * - **Immutable**: Instances cannot be mutated after construction.
22
- * - **Explicit construction**: Instances must be created via `parse()` or
23
- * `fromJSON()`. Direct construction via `new` is intentionally disallowed.
24
- * - **Derived projections**: Query bounds, collation keys, and serializations
21
+ * - **Explicit construction**: Instances must be created via `parse()`.
22
+ * Direct construction via `new` is intentionally disallowed.
23
+ * - **Derived projections**: Query bounds and collation keys
25
24
  * are all derived from a single internal model to guarantee consistency.
26
25
  * - **UTC semantics**: All date calculations and comparisons are performed
27
26
  * using UTC to avoid timezone-related ambiguity.
@@ -31,10 +30,7 @@ import { FuzzyDateJson } from './helpers/types';
31
30
  * ### Intended usage
32
31
  *
33
32
  * - Use `parse()` for untrusted, human-entered input.
34
- * - Use `fromJSON()` when hydrating from a trusted serialized model
35
- * (e.g. database JSON).
36
- * - Store the JSON model as the canonical representation.
37
- * - Store `lowerBound`, `upperBound`, and `collationKey` separately for
33
+ * - Store `lowerBound`, `upperBound`, and `collationKeys` separately for
38
34
  * efficient querying and ordering.
39
35
  */
40
36
  export declare class FuzzyDate {
@@ -48,7 +44,7 @@ export declare class FuzzyDate {
48
44
  /**
49
45
  * Private constructor to enforce factory-based creation.
50
46
  *
51
- * Consumers must use {@link FuzzyDate.parse} or {@link FuzzyDate.fromJSON}.
47
+ * Consumers must use {@link FuzzyDate.parse}.
52
48
  */
53
49
  private constructor();
54
50
  /**
@@ -81,97 +77,78 @@ export declare class FuzzyDate {
81
77
  *
82
78
  * This bound is intended for **endpoint-in-window** searching:
83
79
  * a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
84
- * if **either** its `lowerBound` **or** its `upperBound` falls within the
80
+ * if **either** its `earliest` **or** its `latest` falls within the
85
81
  * search range.
86
82
  *
87
- * Formally (inclusive):
88
- *
89
83
  * ```text
90
- * (searchStart <= lowerBound <= searchEnd) OR (searchStart <= upperBound <= searchEnd)
84
+ * (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
91
85
  * ```
92
86
  *
93
- * ## Unbounded intervals
94
- *
95
- * Open-ended intervals are represented with `null`:
96
- * - `lowerBound === null` means unbounded in the past (−∞)
97
- * - `upperBound === null` means unbounded in the future (+∞)
98
- *
99
- * When using the predicate above, treat a `null` bound as not satisfying
100
- * the endpoint check (i.e., it is not “within” any finite window).
101
- *
102
87
  * @remarks
103
- * - Derived from the canonical model.
104
88
  * - Always interpreted as UTC.
105
89
  */
106
- get lowerBound(): Date | null;
90
+ get earliest(): Date;
107
91
  /**
108
92
  * The inclusive upper bound of this fuzzy date's possible interval (UTC).
109
93
  *
110
94
  * This bound is intended for **endpoint-in-window** searching:
111
95
  * a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
112
- * if **either** its `lowerBound` **or** its `upperBound` falls within the
96
+ * if **either** its `earliest` **or** its `latest` falls within the
113
97
  * search range.
114
98
  *
115
- * Formally (inclusive):
116
- *
117
99
  * ```text
118
- * (searchStart <= lowerBound <= searchEnd) OR (searchStart <= upperBound <= searchEnd)
100
+ * (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
119
101
  * ```
120
102
  *
121
- * ## Unbounded intervals
122
- *
123
- * Open-ended intervals are represented with `null`:
124
- * - `lowerBound === null` means unbounded in the past (−∞)
125
- * - `upperBound === null` means unbounded in the future (+∞)
126
- *
127
- * When using the predicate above, treat a `null` bound as not satisfying
128
- * the endpoint check (i.e., it is not “within” any finite window).
129
- *
130
103
  * @remarks
131
- * - Derived from the canonical model.
132
104
  * - Always interpreted as UTC.
133
105
  */
134
- get upperBound(): Date | null;
106
+ get latest(): Date;
135
107
  /**
136
- * Collation key used for chronological ordering.
108
+ * A lexicographically sortable tuple representing this fuzzy date.
137
109
  *
138
- * The collation key is a derived string whose lexicographic ordering
139
- * is guaranteed to match the semantic chronological ordering of fuzzy dates.
110
+ * Sort **ascending** by each element in order to achieve correct chronological ordering.
140
111
  *
141
- * ```text
142
- * a occurs before b ⇔ a.collationKey < b.collationKey
143
- * ```
112
+ * Tuple structure:
144
113
  *
145
- * This property is intended for:
146
- * - database indexing
147
- * - `ORDER BY` clauses
148
- * - stable sorting without parsing
114
+ * 1. `primary` (number)
115
+ * - Epoch milliseconds used as the primary chronological anchor.
116
+ * - For closed ranges and simple dates: the start minimum.
117
+ * - For left-open ranges: the end minimum.
118
+ * - For right-open ranges: the start maximum.
149
119
  *
150
- * @remarks
151
- * - Not human-readable
152
- * - Not intended for round-tripping
153
- * - Derived entirely from the canonical model
154
- */
155
- get collationKey(): string;
156
- /**
157
- * Serializes this fuzzy date to its canonical JSON representation.
120
+ * 2. `timeRelation` (-1 | 0 | 1)
121
+ * - Distinguishes range type.
122
+ * - `-1` left-open range (… end)
123
+ * - `0` → closed range or simple date
124
+ * - `1` → right-open range (start …)
158
125
  *
159
- * The returned object is suitable for long-term storage,
160
- * transport, and later reconstruction via {@link fromJSON}.
126
+ * 3. `range` (number)
127
+ * - Total span of the range in milliseconds.
128
+ * - For simple dates, this represents the precision window.
129
+ * - Wider ranges sort before smaller ones.
130
+ *
131
+ * 4. `approximate` (0 | 1)
132
+ * - `0` → approximate
133
+ * - `1` → exact
134
+ * - Approximate values sort first.
135
+ *
136
+ * Example (SQL):
137
+ *
138
+ * ```sql
139
+ * ORDER BY primary ASC,
140
+ * timeRelation ASC,
141
+ * range ASC,
142
+ * approximate ASC
143
+ * ```
161
144
  */
162
- toJSON(): FuzzyDateJson;
145
+ get collationKeys(): readonly [number, -1 | 0 | 1, number, 0 | 1];
163
146
  /**
164
- * Reconstructs a `FuzzyDate` from a previously serialized canonical model.
147
+ * Gets whether the date is marked as approximate.
165
148
  *
166
- * @param data A trusted `FuzzyDateModel`, typically loaded from storage.
149
+ * @returns True if the underlying model marks the date as approximate; otherwise false.
167
150
  */
168
- static fromJSON(data: unknown): {
169
- ok: true;
170
- value: FuzzyDate;
171
- } | {
172
- ok: false;
173
- error: "Invalid JSON data.";
174
- };
151
+ get approximate(): boolean;
175
152
  /**
176
153
  * Parses a human-readable date string into a `FuzzyDate`.
177
154
  *
@@ -203,4 +180,43 @@ export declare class FuzzyDate {
203
180
  ok: true;
204
181
  value: FuzzyDate;
205
182
  };
183
+ /**
184
+ * Comparator for sorting {@link FuzzyDate} instances chronologically.
185
+ *
186
+ * This method performs a lexicographic ascending comparison using each
187
+ * instance’s {@link FuzzyDate.collationKeys} tuple.
188
+ *
189
+ * Sort precedence (ascending):
190
+ * 1. Primary epoch anchor (milliseconds)
191
+ * 2. Time relation (-1 | 0 | 1)
192
+ * 3. Range span (milliseconds)
193
+ * 4. Approximate flag (0 = approximate, 1 = exact)
194
+ *
195
+ * Designed for use with `Array.prototype.sort`.
196
+ *
197
+ * @param a - The first {@link FuzzyDate} to compare.
198
+ * @param b - The second {@link FuzzyDate} to compare.
199
+ * @returns
200
+ * - A negative number if `a` should sort before `b`
201
+ * - A positive number if `a` should sort after `b`
202
+ * - `0` if they are considered equivalent for sorting
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * dates.sort(FuzzyDate.sort);
207
+ * ```
208
+ */
209
+ static sort(a: FuzzyDate, b: FuzzyDate): number;
210
+ /**
211
+ * Filters dates using endpoint-in-window semantics.
212
+ *
213
+ * A date matches when either endpoint (`earliest` or `latest`) is inside
214
+ * the inclusive UTC window `[searchStart, searchEnd]`.
215
+ *
216
+ * @param searchStart Inclusive window start (UTC).
217
+ * @param searchEnd Inclusive window end (UTC).
218
+ * @param dates Collection of fuzzy dates to evaluate.
219
+ * @returns Sublist of dates that match the query.
220
+ */
221
+ static query(searchStart: Date, searchEnd: Date, dates: readonly FuzzyDate[]): FuzzyDate[];
206
222
  }
@@ -0,0 +1,238 @@
1
+ import { parse } from './parse/index';
2
+ import { normalize } from './normalize/normalize';
3
+ import { toGedcomX } from './gedcomX/toGedcomX';
4
+ import { ok } from './helpers/result';
5
+ import { DATE_NEG_INFINITY, DATE_POS_INFINITY } from './helpers/constants';
6
+ import { collate } from './collation/collationKey';
7
+ /**
8
+ * Represents an immutable, parsed fuzzy date suitable for genealogy and
9
+ * historical data contexts.
10
+ *
11
+ * A `FuzzyDate` encapsulates a human-entered date expression
12
+ * (e.g. `"before 1900"`, `"between 1 Jan 1900 and 31 Dec 1901"`,
13
+ * `"March 1890"`) and exposes multiple *derived representations* optimized
14
+ * for different use cases:
15
+ *
16
+ * - A canonical JSON model for storage and transport
17
+ * - Query bounds for database filtering
18
+ * - A collation key for stable chronological ordering
19
+ * - A normalized, human-readable string form
20
+ * - A GEDCOM X formal date representation
21
+ *
22
+ * ---
23
+ *
24
+ * ### Design principles
25
+ *
26
+ * - **Immutable**: Instances cannot be mutated after construction.
27
+ * - **Explicit construction**: Instances must be created via `parse()`.
28
+ * Direct construction via `new` is intentionally disallowed.
29
+ * - **Derived projections**: Query bounds and collation keys
30
+ * are all derived from a single internal model to guarantee consistency.
31
+ * - **UTC semantics**: All date calculations and comparisons are performed
32
+ * using UTC to avoid timezone-related ambiguity.
33
+ *
34
+ * ---
35
+ *
36
+ * ### Intended usage
37
+ *
38
+ * - Use `parse()` for untrusted, human-entered input.
39
+ * - Store `lowerBound`, `upperBound`, and `collationKeys` separately for
40
+ * efficient querying and ordering.
41
+ */
42
+ export class FuzzyDate {
43
+ /**
44
+ * Canonical internal model representing the parsed fuzzy date.
45
+ *
46
+ * This model is considered the single source of truth; all other
47
+ * representations are derived from it.
48
+ */
49
+ _model;
50
+ /**
51
+ * Private constructor to enforce factory-based creation.
52
+ *
53
+ * Consumers must use {@link FuzzyDate.parse}.
54
+ */
55
+ constructor(model) {
56
+ this._model = model;
57
+ }
58
+ /**
59
+ * GEDCOM X formal date representation of this fuzzy date.
60
+ *
61
+ * This value is suitable for interoperability with genealogy systems
62
+ * that support the GEDCOM X date specification.
63
+ *
64
+ * @see https://github.com/FamilySearch/gedcomx/blob/master/specifications/date-format-specification.md
65
+ *
66
+ * @remarks
67
+ * This representation is derived and may evolve as GEDCOM X mapping
68
+ * rules are refined.
69
+ */
70
+ get formal() {
71
+ return toGedcomX(this._model);
72
+ }
73
+ /**
74
+ * Normalized, human-readable representation of the fuzzy date.
75
+ *
76
+ * This format represents the interpreted meaning of the original input
77
+ * using a standardized vocabulary and ordering.
78
+ *
79
+ * Example outputs:
80
+ * - `"before 1900"`
81
+ * - `"March 1890"`
82
+ * - `"between 1 Jan 1900 and 31 Dec 1901"`
83
+ */
84
+ get normalized() {
85
+ return normalize(this._model);
86
+ }
87
+ /**
88
+ * The inclusive lower bound of this fuzzy date's possible interval (UTC).
89
+ *
90
+ * This bound is intended for **endpoint-in-window** searching:
91
+ * a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
92
+ * if **either** its `earliest` **or** its `latest` falls within the
93
+ * search range.
94
+ *
95
+ * ```text
96
+ * (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
97
+ * ```
98
+ *
99
+ * @remarks
100
+ * - Always interpreted as UTC.
101
+ */
102
+ get earliest() {
103
+ return this._model.start?.min ?? DATE_NEG_INFINITY;
104
+ }
105
+ /**
106
+ * The inclusive upper bound of this fuzzy date's possible interval (UTC).
107
+ *
108
+ * This bound is intended for **endpoint-in-window** searching:
109
+ * a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
110
+ * if **either** its `earliest` **or** its `latest` falls within the
111
+ * search range.
112
+ *
113
+ * ```text
114
+ * (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
115
+ * ```
116
+ *
117
+ * @remarks
118
+ * - Always interpreted as UTC.
119
+ */
120
+ get latest() {
121
+ return this._model.end?.max ?? DATE_POS_INFINITY;
122
+ }
123
+ /**
124
+ * A lexicographically sortable tuple representing this fuzzy date.
125
+ *
126
+ * Sort **ascending** by each element in order to achieve correct chronological ordering.
127
+ *
128
+ * Tuple structure:
129
+ *
130
+ * 1. `primary` (number)
131
+ * - Epoch milliseconds used as the primary chronological anchor.
132
+ * - For closed ranges and simple dates: the start minimum.
133
+ * - For left-open ranges: the end minimum.
134
+ * - For right-open ranges: the start maximum.
135
+ *
136
+ * 2. `timeRelation` (-1 | 0 | 1)
137
+ * - Distinguishes range type.
138
+ * - `-1` → left-open range (… end)
139
+ * - `0` → closed range or simple date
140
+ * - `1` → right-open range (start …)
141
+ *
142
+ * 3. `range` (number)
143
+ * - Total span of the range in milliseconds.
144
+ * - For simple dates, this represents the precision window.
145
+ * - Wider ranges sort before smaller ones.
146
+ *
147
+ * 4. `approximate` (0 | 1)
148
+ * - `0` → approximate
149
+ * - `1` → exact
150
+ * - Approximate values sort first.
151
+ *
152
+ * Example (SQL):
153
+ *
154
+ * ```sql
155
+ * ORDER BY primary ASC,
156
+ * timeRelation ASC,
157
+ * range ASC,
158
+ * approximate ASC
159
+ * ```
160
+ */
161
+ get collationKeys() {
162
+ return collate(this._model);
163
+ }
164
+ /**
165
+ * Gets whether the date is marked as approximate.
166
+ *
167
+ * @returns True if the underlying model marks the date as approximate; otherwise false.
168
+ */
169
+ get approximate() {
170
+ return this._model.approximate;
171
+ }
172
+ /**
173
+ * Parses a human-readable date string into a `FuzzyDate`.
174
+ *
175
+ * This method should be used for all untrusted or user-provided input.
176
+ *
177
+ * @param input Human-readable date expression.
178
+ * @returns A result object containing either a `FuzzyDate` or parse issues.
179
+ *
180
+ * @remarks
181
+ * - Parsing is non-throwing; errors are returned as structured issues.
182
+ * - Successful results are guaranteed to produce a valid canonical model.
183
+ */
184
+ static parse(input) {
185
+ const result = parse(input);
186
+ if (!result.ok)
187
+ return result;
188
+ const date = new FuzzyDate(result.value);
189
+ return ok(date);
190
+ }
191
+ /**
192
+ * Comparator for sorting {@link FuzzyDate} instances chronologically.
193
+ *
194
+ * This method performs a lexicographic ascending comparison using each
195
+ * instance’s {@link FuzzyDate.collationKeys} tuple.
196
+ *
197
+ * Sort precedence (ascending):
198
+ * 1. Primary epoch anchor (milliseconds)
199
+ * 2. Time relation (-1 | 0 | 1)
200
+ * 3. Range span (milliseconds)
201
+ * 4. Approximate flag (0 = approximate, 1 = exact)
202
+ *
203
+ * Designed for use with `Array.prototype.sort`.
204
+ *
205
+ * @param a - The first {@link FuzzyDate} to compare.
206
+ * @param b - The second {@link FuzzyDate} to compare.
207
+ * @returns
208
+ * - A negative number if `a` should sort before `b`
209
+ * - A positive number if `a` should sort after `b`
210
+ * - `0` if they are considered equivalent for sorting
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * dates.sort(FuzzyDate.sort);
215
+ * ```
216
+ */
217
+ static sort(a, b) {
218
+ return (a.collationKeys[0] - b.collationKeys[0] ||
219
+ a.collationKeys[1] - b.collationKeys[1] ||
220
+ a.collationKeys[2] - b.collationKeys[2] ||
221
+ a.collationKeys[3] - b.collationKeys[3]);
222
+ }
223
+ /**
224
+ * Filters dates using endpoint-in-window semantics.
225
+ *
226
+ * A date matches when either endpoint (`earliest` or `latest`) is inside
227
+ * the inclusive UTC window `[searchStart, searchEnd]`.
228
+ *
229
+ * @param searchStart Inclusive window start (UTC).
230
+ * @param searchEnd Inclusive window end (UTC).
231
+ * @param dates Collection of fuzzy dates to evaluate.
232
+ * @returns Sublist of dates that match the query.
233
+ */
234
+ static query(searchStart, searchEnd, dates) {
235
+ return dates.filter((date) => (searchStart <= date.earliest && date.earliest <= searchEnd) ||
236
+ (searchStart <= date.latest && date.latest <= searchEnd));
237
+ }
238
+ }