@esmalley/ts-utils 6.4.5 → 7.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.
package/README.md CHANGED
@@ -20,15 +20,19 @@ A modular collection of TypeScript utilities for modern web development.
20
20
  * [CSV](#csv)
21
21
  * [Dates](#dates)
22
22
  * [Kontororu (Events)](#kontororu-events)
23
+ * [Numbers](#numbers)
23
24
  * [Objector](#objector)
24
25
  * [Sorter](#sorter)
25
26
  * [Style](#style)
27
+ * [Tasker](#tasker)
26
28
  * [Textor](#textor)
27
29
  * [Theme](#theme)
28
30
  * [Toaster](#toaster)
31
+ * [UuidService](#uuidservice)
29
32
 
30
33
 
31
34
  * [Testing](#testing)
35
+ * [Benchmarks](#benchmarks)
32
36
  * [License](#license)
33
37
 
34
38
  ---
@@ -76,6 +80,48 @@ console.log(actualHealth); // Output: 100
76
80
  ```
77
81
 
78
82
 
83
+ ### `lerp(a, b, amount)`
84
+
85
+ Linearly interpolates between two numbers. `amount` is clamped to 0–1, so the result never overshoots.
86
+
87
+ ```ts
88
+ Arithmetic.lerp(0, 100, 0.25); // 25
89
+ ```
90
+
91
+ ### `round(value, precision?)`
92
+
93
+ Rounds to a fixed number of decimal places, handling the half-way cases that naive scaling gets wrong. A negative precision rounds to tens, hundreds, and so on.
94
+
95
+ ```ts
96
+ Arithmetic.round(1.005, 2); // 1.01 (not 1 as with Math.round(v * 100) / 100)
97
+ Arithmetic.round(1234, -2); // 1200
98
+ ```
99
+
100
+ ### `mapRange(value, inMin, inMax, outMin, outMax)` / `normalize(value, min, max)`
101
+
102
+ Re-maps a number from one range onto another. `normalize` is the special case that maps onto 0–1, and is the inverse of `lerp`.
103
+
104
+ ```ts
105
+ // A 1000-2000 rating expressed as a 0-100 score
106
+ Arithmetic.mapRange(1500, 1000, 2000, 0, 100); // 50
107
+ Arithmetic.normalize(1500, 1000, 2000); // 0.5
108
+ ```
109
+
110
+ ### Descriptive statistics
111
+
112
+ `sum`, `mean`, `median`, `mode`, `variance`, `stdDev`, and `percentile`.
113
+
114
+ Aggregates over an empty list return `NaN` rather than `0`, so an empty data set is visibly empty instead of silently reading as a real zero. `variance` and `stdDev` are sample statistics (dividing by `n - 1`); pass `true` as the second argument for the population form.
115
+
116
+ ```ts
117
+ const scores = [88, 72, 95, 64, 79];
118
+
119
+ Arithmetic.mean(scores); // 79.6
120
+ Arithmetic.median(scores); // 79
121
+ Arithmetic.stdDev(scores); // 11.97...
122
+ Arithmetic.percentile(scores, 90); // 92.2
123
+ Arithmetic.mode([1, 2, 2, 3]); // [2] (every tied value, in first-seen order)
124
+ ```
79
125
 
80
126
  ---
81
127
 
@@ -112,7 +158,62 @@ const matchups = Arrayifier.getCombinations(players, 2);
112
158
 
113
159
  ```
114
160
 
161
+ A three-argument form, `getCombinations(arr, n, r)`, draws from only the first `n` elements.
162
+
163
+ ### `groupBy(arr, keyFn)` / `countBy(arr, keyFn)`
164
+
165
+ Buckets or counts items by a derived key.
166
+
167
+ ```ts
168
+ Arrayifier.groupBy(games, (g) => g.season);
169
+ // { 2025: [...], 2026: [...] }
170
+
171
+ Arrayifier.countBy(games, (g) => g.status);
172
+ // { final: 12, live: 3 }
173
+ ```
174
+
175
+ ### `chunk(arr, size)`
176
+
177
+ Splits an array into consecutive chunks of at most `size`.
178
+
179
+ ```ts
180
+ Arrayifier.chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]]
181
+ ```
182
+
183
+ ### `unique(arr)` / `uniqueBy(arr, keyFn)`
184
+
185
+ Removes duplicates, keeping the first occurrence. `unique` compares by identity; `uniqueBy` compares a derived key, which is what you want for objects.
186
+
187
+ ```ts
188
+ Arrayifier.unique([1, 2, 2, 3]); // [1, 2, 3]
189
+ Arrayifier.uniqueBy(players, (p) => p.team_id);
190
+ ```
115
191
 
192
+ ### `sortBy(arr, keyFn, direction?)`
193
+
194
+ Sorts by a derived value without mutating the input. Numbers compare numerically, everything else as strings, and null/undefined keys sort last in both directions.
195
+
196
+ ```ts
197
+ Arrayifier.sortBy(teams, (t) => t.rating, 'desc');
198
+ ```
199
+
200
+ ### `partition(arr, predicate)`
201
+
202
+ Splits into the items that satisfy the predicate and those that do not.
203
+
204
+ ```ts
205
+ const [wins, losses] = Arrayifier.partition(games, (g) => g.won);
206
+ ```
207
+
208
+ ### `range(start, end?, step?)`
209
+
210
+ A sequence of numbers, excluding the end. With one argument, counts from 0.
211
+
212
+ ```ts
213
+ Arrayifier.range(4); // [0, 1, 2, 3]
214
+ Arrayifier.range(1, 4); // [1, 2, 3]
215
+ Arrayifier.range(3, 0, -1); // [3, 2, 1]
216
+ ```
116
217
 
117
218
  ---
118
219
 
@@ -173,7 +274,20 @@ console.log(overlay); // Output: rgba(0, 0, 0, 0.5)
173
274
 
174
275
  ```
175
276
 
277
+ ### Conversions: `hexToRgb`, `rgbToHex`, `rgbToHsl`, `hslToRgb`
176
278
 
279
+ The full set of conversions is public, so you can move between representations in either direction. `hexToRgb` accepts shorthand (`#fff`) and throws on malformed input rather than silently reporting black. `rgbToHex` rounds and clamps its channels, so an out-of-range value cannot produce a malformed string.
280
+
281
+ ```ts
282
+ Color.hexToRgb('#ff8000'); // [255, 128, 0]
283
+ Color.rgbToHex(255, 128, 0); // '#FF8000'
284
+ Color.rgbToHsl(255, 0, 0); // [0, 100, 50] (hue 0-360, sat/lightness 0-100)
285
+ Color.hslToRgb(0, 100, 50); // [255, 0, 0]
286
+
287
+ // Rotate a hue without leaving hex
288
+ const [h, s, l] = Color.rgbToHsl(...Color.hexToRgb('#3498db'));
289
+ const shifted = Color.rgbToHex(...Color.hslToRgb((h + 180) % 360, s, l));
290
+ ```
177
291
 
178
292
  ---
179
293
 
@@ -181,6 +295,27 @@ console.log(overlay); // Output: rgba(0, 0, 0, 0.5)
181
295
 
182
296
  Utilities for data exportation.
183
297
 
298
+ ### `stringify(data)`
299
+
300
+ Converts a nested object into an RFC 4180 CSV string. Headers are the union of every row's keys, so a row carrying extra columns is not truncated to the shape of the first one. Values containing a comma, quote or newline are quoted, and embedded quotes are doubled. `null` and `undefined` become empty cells, while `0`, `false` and `''` survive as written.
301
+
302
+ * **Example 1: Building a CSV in Memory**
303
+ ```ts
304
+ import { CSV } from '@esmalley/ts-utils';
305
+
306
+ const csv = CSV.stringify({
307
+ 1: { team: 'Tigers', wins: 0, note: 'Rebuilding, "again"' },
308
+ 2: { team: 'Bears', wins: 12, streak: 3 },
309
+ });
310
+
311
+ // team,wins,note,streak
312
+ // Tigers,0,"Rebuilding, ""again""",
313
+ // Bears,12,,3
314
+
315
+ ```
316
+
317
+
318
+
184
319
  ### `download(data)`
185
320
 
186
321
  Takes a nested object and triggers a browser download of a generated `.csv` file.
@@ -257,6 +392,154 @@ console.log(Dates.isSameDay(d1, d2)); // true
257
392
 
258
393
  ```
259
394
 
395
+ ### Period boundaries
396
+
397
+ `getStartOfDay` / `getEndOfDay`, `getStartOfMonth` / `getEndOfMonth`, and `getDaysInMonth`.
398
+
399
+ ```ts
400
+ Dates.getEndOfDay('2026-03-15'); // 2026-03-15 23:59:59.999 local
401
+ Dates.getEndOfMonth('2026-02-10'); // 2026-02-28 23:59:59.999
402
+ Dates.getDaysInMonth('2024-02-01'); // 29
403
+ ```
404
+
405
+ ### `eachDayOfInterval(start, end)`
406
+
407
+ Every day from `start` to `end` inclusive, as local midnights. Steps by calendar day, so it stays correct across DST boundaries.
408
+
409
+ * **Example: Building a month grid**
410
+ Pair with `getStartOfGrid` / `getEndOfGrid` to get whole Sunday-to-Saturday weeks covering the month.
411
+ ```ts
412
+ const cells = Dates.eachDayOfInterval(
413
+ Dates.getStartOfGrid('2026-03-01'),
414
+ Dates.getEndOfGrid('2026-03-01'),
415
+ );
416
+ // cells.length is always a multiple of 7
417
+ ```
418
+
419
+ ### `isBetween(date, start, end, inclusive?)`
420
+
421
+ Whether a date falls between two others. The bounds may be given in either order, and comparison is by exact instant — pair with `getStartOfDay` / `getEndOfDay` for a whole-day range.
422
+
423
+ ```ts
424
+ Dates.isBetween('2026-03-05', '2026-03-01', '2026-03-10'); // true
425
+ ```
426
+
427
+ ---
428
+
429
+ ## Tasker
430
+
431
+ Wrappers for controlling how and when other functions run.
432
+
433
+ ### `debounce(fn, wait, leading?)`
434
+
435
+ Delays a function until it has stopped being called for `wait` ms. The returned function carries `cancel()`, `flush()`, and `pending()`.
436
+
437
+ * **Example: Search-as-you-type**
438
+ ```ts
439
+ import { Tasker } from '@esmalley/ts-utils';
440
+
441
+ const search = Tasker.debounce((term: string) => runQuery(term), 300);
442
+
443
+ search('gon');
444
+ search('gonz'); // only this one runs, 300ms after the last keystroke
445
+ ```
446
+
447
+ ### `throttle(fn, wait)`
448
+
449
+ Allows a function to run at most once per `wait` ms. The first call runs immediately; a call made during the cooling-off period runs when it ends, carrying the most recent arguments.
450
+
451
+ ```ts
452
+ window.addEventListener('scroll', Tasker.throttle(() => measure(), 100));
453
+ ```
454
+
455
+ ### `retry(fn, options?)`
456
+
457
+ Calls an async function until it succeeds, backing off between attempts. Rethrows the final error once attempts are exhausted.
458
+
459
+ ```ts
460
+ const data = await Tasker.retry(() => fetchRatings(), {
461
+ attempts: 5,
462
+ delay: 500,
463
+ onRetry: (error, attempt) => console.warn(`retry ${attempt}`, error),
464
+ });
465
+ ```
466
+
467
+ ### `backoff(attempt, options?)`
468
+
469
+ The delay before a given retry attempt, growing exponentially and capped at `maxDelay`. Pass `jitter` to randomize across `0..computed` and avoid a thundering herd.
470
+
471
+ ```ts
472
+ Tasker.backoff(0); // 1000
473
+ Tasker.backoff(3); // 8000
474
+ ```
475
+
476
+ ### `memoize(fn, keyFn?)` / `once(fn)`
477
+
478
+ Caches results by arguments, or restricts a function to a single call. The memoized function carries `clear()` and `size()`.
479
+
480
+ ```ts
481
+ const ratingFor = Tasker.memoize((teamId: number) => compute(teamId));
482
+ const init = Tasker.once(() => connect());
483
+ ```
484
+
485
+ ### `sleep(ms)`
486
+
487
+ Resolves after a delay.
488
+
489
+ ```ts
490
+ await Tasker.sleep(250);
491
+ ```
492
+
493
+ ---
494
+
495
+ ## Numbers
496
+
497
+ Human-readable number formatting.
498
+
499
+ ### `formatOrdinal(n)` / `ordinalSuffix(n)`
500
+
501
+ A number with its English ordinal suffix, or just the suffix.
502
+
503
+ ```ts
504
+ Numbers.formatOrdinal(1); // '1st'
505
+ Numbers.formatOrdinal(23); // '23rd'
506
+ Numbers.ordinalSuffix(11); // 'th'
507
+ ```
508
+
509
+ ### `format(value, decimals?, locale?)` / `formatSigned(value, decimals?)`
510
+
511
+ Locale grouping separators, optionally with an explicit sign for deltas such as a change in rank.
512
+
513
+ ```ts
514
+ Numbers.format(1234567); // '1,234,567'
515
+ Numbers.format(1234.5, 2); // '1,234.50'
516
+ Numbers.formatSigned(3); // '+3'
517
+ ```
518
+
519
+ ### `formatCompact(value, decimals?)`
520
+
521
+ A large number shortened with a magnitude suffix. Implemented directly rather than through Intl's compact notation, whose exact output varies with the runtime's ICU data.
522
+
523
+ ```ts
524
+ Numbers.formatCompact(1234); // '1.2K'
525
+ Numbers.formatCompact(2_000_000); // '2M'
526
+ ```
527
+
528
+ ### `formatPercent(value, decimals?, fromRatio?)`
529
+
530
+ A ratio as a percentage. Pass `false` for `fromRatio` if the input is already scaled.
531
+
532
+ ```ts
533
+ Numbers.formatPercent(0.1234); // '12.3%'
534
+ ```
535
+
536
+ ### `formatDuration(ms, parts?)` / `formatBytes(bytes, decimals?)`
537
+
538
+ ```ts
539
+ Numbers.formatDuration(3_725_000); // '1h 2m'
540
+ Numbers.formatDuration(3_725_000, 3); // '1h 2m 5s'
541
+ Numbers.formatBytes(1536); // '1.5 KB'
542
+ ```
260
543
 
261
544
  ---
262
545
 
@@ -293,6 +576,29 @@ Objector.extender(defaults, userConfig);
293
576
 
294
577
  ```
295
578
 
579
+ ### `deepEqual(a, b)`
580
+
581
+ Structurally compares two values — the counterpart to `deepClone`. Handles the same shapes (Date, RegExp, Map, Set, arrays, plain objects, symbol keys) and tolerates circular references.
582
+
583
+ `NaN` equals `NaN` and `+0` does not equal `-0`, matching `Object.is` rather than `===`. Objects must share a prototype to be considered equal.
584
+
585
+ * **Example 1: Change detection**
586
+ ```ts
587
+ if (!Objector.deepEqual(previousFilters, nextFilters)) {
588
+ refetch();
589
+ }
590
+
591
+ ```
592
+
593
+ * **Example 2: Verifying a clone**
594
+ ```ts
595
+ const copy = Objector.deepClone(state);
596
+ Objector.deepEqual(state, copy); // true
597
+ copy.user.id = 2;
598
+ Objector.deepEqual(state, copy); // false
599
+
600
+ ```
601
+
296
602
 
297
603
 
298
604
  ---
@@ -362,6 +668,14 @@ const shadow = Style.getShadow(4);
362
668
 
363
669
  String manipulation and linguistic utility functions.
364
670
 
671
+ ### `toKebabCase(str)`
672
+
673
+ Converts camelCase or PascalCase to kebab-case. Used internally to turn CSS-in-JS property names into real CSS properties, but general purpose.
674
+
675
+ ```ts
676
+ Textor.toKebabCase('backgroundColor'); // 'background-color'
677
+ ```
678
+
365
679
  ### `levenshtein(a, b)`
366
680
 
367
681
  Calculates the Levenshtein distance between two strings. This is a string metric for measuring the difference between two sequences (the minimum number of single-character edits required to change one word into the other).
@@ -479,27 +793,6 @@ console.log(light.background.main); // #ffffff
479
793
 
480
794
 
481
795
 
482
- ### Palette Accessors (e.g., `getGrey()`, `getAmber()`, etc.)
483
-
484
- The class provides access to the standard Material Design color ramps.
485
-
486
- * **Example 1: Using Specific Color Weights**
487
- ```ts
488
- const theme = new Theme('light');
489
- const greys = theme.getGrey();
490
-
491
- const dividerStyle = {
492
- backgroundColor: greys[300] // Light grey for dividers
493
- };
494
-
495
- const secondaryText = {
496
- color: greys[600] // Medium grey for subtext
497
- };
498
-
499
- ```
500
-
501
-
502
-
503
796
  ---
504
797
 
505
798
  ## Toaster
@@ -528,7 +821,7 @@ const unsubscribe = toaster.subscribe((newList) => {
528
821
 
529
822
  ### `add(message, type)`
530
823
 
531
- Adds a new notification to the stack. Defaults to `'info'` type. Automatically triggers a close request after 4 seconds.
824
+ Adds a new notification to the stack and returns its id, which `requestClose(id)` and `remove(id)` take. Defaults to `'info'` type. Automatically triggers a close request after `Toaster.AUTO_DISMISS_MS` (4 seconds); the exit phase then lasts `Toaster.EXIT_ANIMATION_MS` (500ms). Both are public constants, so a UI can drive its animation from the same numbers.
532
825
 
533
826
  * **Example 1: Error Handling**
534
827
  ```ts
@@ -546,13 +839,20 @@ try {
546
839
  ```ts
547
840
  toaster.add("You have a new message."); // Defaults to 'info'
548
841
 
842
+ // Hold on to the id to dismiss a toast before it auto-dismisses.
843
+ const id = toaster.add("Uploading...");
844
+ await upload();
845
+ toaster.remove(id);
846
+
549
847
  ```
550
848
 
551
849
 
552
850
 
553
851
  ### `requestClose(id)`
554
852
 
555
- Starts the "exit" phase for a toast. It marks the toast as `exiting: true`, allowing the UI to play a fade-out animation before the toast is fully removed 500ms later.
853
+ Starts the "exit" phase for a toast. It marks the toast as `exiting: true` so the UI can play a fade-out animation.
854
+
855
+ Your UI is expected to call `remove(id)` once that animation ends — that is the normal path, and it is what gets the toast off the list. As a safety net, the toast is also dropped 500ms later in case the animation callback never arrives (a backgrounded tab, an unmount mid-animation, reduced-motion settings). That late removal is silent when the UI already handled it, so it costs no extra render.
556
856
 
557
857
  * **Example 1: Manual Dismiss Button**
558
858
  ```ts
@@ -563,11 +863,24 @@ const handleClose = (toastId) => {
563
863
 
564
864
  ```
565
865
 
866
+ * **Example 2: Removing when the animation ends**
867
+ ```tsx
868
+ <div
869
+ className={isExiting ? 'toast-fade-out' : 'toast-slide-up'}
870
+ onAnimationEnd={() => {
871
+ if (isExiting) {
872
+ toaster.remove(toast.id);
873
+ }
874
+ }}
875
+ >
876
+
877
+ ```
878
+
566
879
 
567
880
 
568
881
  ### `remove(id)`
569
882
 
570
- Immediately removes a toast from the list without waiting for an animation or timeout.
883
+ Immediately removes a toast from the list without waiting for an animation or timeout. Removing an id that is not present is a no-op and notifies nobody.
571
884
 
572
885
  * **Example 1: Force Clearing a specific alert**
573
886
  ```ts
@@ -610,6 +923,104 @@ bus.addEventListener('data', (payload) => console.log(payload));
610
923
 
611
924
  ```
612
925
 
926
+ ---
927
+
928
+ ## UuidService
929
+
930
+ A singleton that generates UUIDv7 identifiers — time-ordered, so they sort chronologically and index well as a primary key.
931
+
932
+ ### `generateUUIDv7Bytes()`
933
+
934
+ Returns the raw 16 bytes. Monotonic within a millisecond via a 12-bit counter, and holds steady if the wall clock jumps backwards.
935
+
936
+ ```ts
937
+ import { uuidService } from '@esmalley/ts-utils';
938
+
939
+ const bytes = uuidService.generateUUIDv7Bytes();
940
+ const id = uuidService.binToUuid(bytes);
941
+ // '01997e4c-1f3a-7b21-9c04-5e6f70818293'
942
+ ```
943
+
944
+ ### `binToUuid(buffer)` / `uuidToBin(uuid)`
945
+
946
+ Converts between the 16-byte form and the canonical dashed string. Storing the binary form in a `BINARY(16)` column costs 16 bytes instead of 36.
947
+
948
+ ```ts
949
+ const bin = uuidService.uuidToBin(id);
950
+ uuidService.binToUuid(bin) === id; // true
951
+ ```
952
+
953
+ ### `isValid(uuid)`
954
+
955
+ Whether a string is a well-formed UUID, dashed or bare.
956
+
957
+ ```ts
958
+ uuidService.isValid(id); // true
959
+ uuidService.isValid('not-a-id'); // false
960
+ ```
961
+
962
+ ---
963
+
964
+ # Testing
965
+
966
+ Unit tests run on Jest in ESM mode:
967
+
968
+ ```bash
969
+ npm test # type-checks, then runs every suite
970
+ npm test -- Arithmetic # one file
971
+ npm run typecheck # tsc --noEmit on its own
972
+ ```
973
+
974
+ `npm test` runs `typecheck` first via `pretest`. That matters because ts-jest is configured with `isolatedModules: true`, which makes it transpile tests without type-checking them — so Jest alone will happily run a test file full of type errors. `tsc --noEmit` covers `src`, `tests` and everything else the root `tsconfig.json` includes, while `npm run build` stays scoped to `src`.
975
+
976
+ ## Benchmarks
977
+
978
+ Correctness is covered by Jest; execution time is covered by a separate benchmark suite built on [tinybench](https://github.com/tinylibs/tinybench). Benchmarks live in `bench/*.bench.js` and read like tests:
979
+
980
+ ```js
981
+ import { Arithmetic } from '../dist/esm/index.js';
982
+ import { suite, bench } from './harness.js';
983
+
984
+ const ratings = Array.from({ length: 10_000 }, () => Math.random() * 2000);
985
+
986
+ suite('Arithmetic (10k values)', () => {
987
+ bench('mean()', () => Arithmetic.mean(ratings));
988
+ bench('median()', () => Arithmetic.median(ratings));
989
+ });
990
+ ```
991
+
992
+ They import from `dist/esm`, not `src`, so they measure what consumers actually install. Build first:
993
+
994
+ ```bash
995
+ npm run build
996
+ npm run bench # run everything
997
+ npm run bench -- Arithmetic # filter by suite or bench name
998
+ npm run bench -- --time=1000 # sample each bench for 1000ms (default 500)
999
+ ```
1000
+
1001
+ ### Catching regressions
1002
+
1003
+ Record the current numbers, make a change, then re-run to see the difference:
1004
+
1005
+ ```bash
1006
+ npm run bench:save # writes bench/baseline.json
1007
+ # ...optimize something...
1008
+ npm run bench # each row gains a delta column
1009
+ ```
1010
+
1011
+ ```
1012
+ Arithmetic (10k values)
1013
+ mean() 12,001 ops/s +-0.9% 84.59 us 1,419 runs same
1014
+ median() 471 ops/s +-4.1% 2.17 ms 64 runs +18.2%
1015
+ ```
1016
+
1017
+ Deltas are throughput, so a positive number is faster. Anything under 5% reports as `same`, because run-to-run noise on a working machine swamps it. The baseline is machine-specific and git-ignored — compare against your own hardware, never someone else's.
1018
+
1019
+ Two annotations flag numbers you should not trust:
1020
+
1021
+ * **`unstable`** — the margin of error exceeded 5%. Re-run with a longer `--time`, or close whatever else is busy.
1022
+ * **`faster than the clock` / `at timer resolution`** — the work finished faster than the platform timer can resolve, so the figure is approximate. Benchmark a realistic batch instead of a single call.
1023
+
613
1024
 
614
1025
 
615
1026