@ultimat3/time 1.1.0 → 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.
package/CLAUDE.md ADDED
@@ -0,0 +1,64 @@
1
+ # @ultimat3/time — agent notes
2
+
3
+ **Tier 1.** May import `@ultimat3/core`, `@ultimat3/schema`. No external deps — **never add
4
+ `date-fns-tz`**; zone math is `Intl.DateTimeFormat` + `formatToParts`.
5
+
6
+ ## Boundary
7
+
8
+ | File | Single responsibility |
9
+ |---|---|
10
+ | `instant.ts` | the UTC `Instant` brand, ISO/epoch conversion, `now(clock)`, `epoch()` |
11
+ | `zones.ts` | IANA validation, `offsetAt` (minutes east), zone labels |
12
+ | `zone-canonical.ts` | one zone, one key: `canonicalTimeZone` — the casing/alias collapse every cache keys on |
13
+ | `locale-canonical.ts` | one locale, one key: `canonicalLocale` — the same collapse for the `Accept-Language` half |
14
+ | `intl-cache.ts` | the one bounded FIFO every `Intl` formatter cache in this package uses |
15
+ | `zoned.ts` | `toZoned` / `fromZoned` + gap and overlap policies. Everything depends on this. |
16
+ | `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. |
17
+ | `duration.ts` | `'2h30m'` ⇄ ms |
18
+ | `cron.ts` | barrel over the three cron modules — the only one `index.ts` re-exports |
19
+ | `cron-parse.ts` | field grammar → `CronExpression`. Non-integer, non-name tokens are rejected. |
20
+ | `cron-occurrence.ts` | next occurrence, wall-clock driven |
21
+ | `cron-describe.ts` | `describeCron` — `Intl` names, phrases injected. `CronPhrases` is required. |
22
+ | `schedule.ts` | `nextLocalSlot` — "09:00 local tomorrow" |
23
+ | `business.ts` | weekends as config, holidays as local dates |
24
+ | `context.ts` | request timezone: which source wins, and reading core's `Ctx.tz` back off the ALS |
25
+
26
+ ## Rules
27
+
28
+ - Never format without an explicit `timeZone`. No ambient default, no `toLocaleString()`.
29
+ - **The ambient zone IS `Ctx.tz`**, core's own declared field. This package publishes no writer and
30
+ no field of its own: `createContext({ tz })` and `withChildContext({ tz })` are the way in,
31
+ `currentTimeZone()` the way out. It kept `attachTimeZone`/`timeZoneOf` over `ctx['timeZone']`
32
+ until 1.3.0 — a second ambient store, with **zero** writers, while `@ultimat3/http` wrote `tz` —
33
+ so `currentTimeZone()` answered `UTC` for every request and every `@ultimat3/ui` server render
34
+ formatted in UTC regardless of the zone the caller sent. Two ambient defaults is the worst
35
+ possible version of the rule above. Never reintroduce either half.
36
+ - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
37
+ a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale`
38
+ for a locale) and the cache must be bounded (`cachedFormatter`, `intl-cache.ts`). An unbounded
39
+ `Map` keyed on `x-timezone` grew 31 MB for 4,096 casings of one zone name, and the casing space
40
+ of a 13-letter zone is 2^12. **Both halves, always** — a canonical key does not bound anything
41
+ (an unknown `-u-` extension value survives canonicalization as a distinct string) and the cap
42
+ alone lets one locale evict itself under three spellings.
43
+ - **Never hand back the caller's own `Date`, and never export a shared one.** `Instant` is a
44
+ branded `Date` and a `Date` cannot be frozen — `Object.freeze` does not close `setTime`, the
45
+ value is in an internal slot. So `instant()` copies and `epoch()` is a function, not a constant.
46
+ - **`businessDaysBetween` is `[from, to)` on local calendar dates** — the interval `daysBetween`
47
+ measures, so the two can never disagree. Comparing instants made the answer depend on the
48
+ endpoints' time of day. A new day-counting function states its interval in its header.
49
+ - **`describeCron` declines what it cannot say.** `CronPhrases` is the caller's vocabulary and has
50
+ no seconds phrase, so a 6-field expression with a non-trivial seconds field is
51
+ `X_CRON_NOT_DESCRIBABLE`. Adding a required field to `CronPhrases` would break every caller
52
+ (`packages/cli/src/cmd-tasks.ts` builds one) to describe a schedule almost nobody writes.
53
+ - Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
54
+ - Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
55
+ - Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
56
+ - `m` is minutes, `ms` is milliseconds. A bare number is not a duration.
57
+ - Tests must cover a spring-forward gap, a fall-back overlap and a non-hour offset zone.
58
+
59
+ ## Commands
60
+
61
+ ```
62
+ bun test packages/time
63
+ bun run --filter @ultimat3/time typecheck
64
+ ```
package/README.md CHANGED
@@ -16,6 +16,20 @@ none ever will — "the server's timezone" is not an answer to "what time is it
16
16
  No `date-fns-tz`, no tzdata table. Zone math is derived from `Intl.DateTimeFormat` +
17
17
  `formatToParts`, which is exact in Bun and always current with the runtime's tzdata.
18
18
 
19
+ **One zone is one key.** `Intl` accepts every casing of an IANA name, so `canonicalTimeZone(z)`
20
+ answers the canonical spelling (or `undefined`), and `assertTimeZone` / `resolveTimeZone` both
21
+ return it. Anything reading a zone off a request header should canonicalize before caching on it:
22
+ 4,096 casings of `Europe/Berlin` used to mint 4,096 permanent `Intl.DateTimeFormat`s, 31 MB.
23
+
24
+ One **locale** is one key for the same reason — `Accept-Language` spells one locale `EN-us`,
25
+ `en-US` and `en-latn-us`, and `formatDateTime` and `describeCron` collapse the three before they
26
+ reach a formatter cache. The cap in `intl-cache.ts` stays either way: an unknown `-u-` extension
27
+ value survives canonicalization as a distinct string, so the key bounds nothing on its own.
28
+
29
+ Every value this package hands back is its own object: `instant(date)` copies rather than branding
30
+ the caller's `Date`, and `epoch()` is a function — the `EPOCH` constant it replaces was one shared
31
+ mutable `Date` that a single `setUTCFullYear` corrupted for the whole process.
32
+
19
33
  ## Use
20
34
 
21
35
  ```ts
@@ -73,13 +87,21 @@ change, and a job scheduled inside the gap runs at the first existing local time
73
87
  being skipped. `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
74
88
  weekday names from `Intl`, every connective word **required** from the caller's `t('time.cron.*')`,
75
89
  because tier 1 cannot reach `t()` and a built-in default would ship English to every locale. A
76
- long clock-time list is capped and the remainder counted with `andMore`, never silently cut.
90
+ long clock-time list is capped and the remainder counted with `andMore`, never silently cut. A
91
+ 6-field expression whose seconds field says something a 5-field one cannot is **declined** with
92
+ `X_CRON_NOT_DESCRIBABLE` rather than summarised: `CronPhrases` has no seconds vocabulary, so a
93
+ ten-second step used to render as "every minute".
77
94
 
78
95
  ## Business days
79
96
 
80
97
  The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the Gulf),
81
98
  `WEEKEND_SUN_ONLY`, plus a holiday list of local `YYYY-MM-DD` dates.
82
99
 
100
+ `businessDaysBetween(from, to, calendar)` counts `[from, to)` — half-open, on **local calendar
101
+ days**, the same interval `daysBetween` measures. `from`'s own day counts, `to`'s does not, and
102
+ neither endpoint's wall-clock time is part of the question. A reversed range is the same count
103
+ negated; an empty one is `0` in either direction, never `-0`.
104
+
83
105
  ## Errors
84
106
 
85
107
  | Code | When |
@@ -91,6 +113,8 @@ The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the
91
113
  | `X_DST_NONEXISTENT` | gap hit with `gap: 'throw'` |
92
114
  | `X_INSTANT_INVALID` | unparseable timestamp |
93
115
  | `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached `describeCron` |
116
+ | `X_CRON_NOT_DESCRIBABLE` | a valid 6-field cron whose seconds field `CronPhrases` has no words for |
117
+ | `X_SCHEDULE_INVALID` | a wall-clock field out of range: `slot.hour`, `slot.minute`, `slot.second`, `slot.weekday` |
94
118
 
95
119
  ## Why it exists
96
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,6 +31,6 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.1.0"
34
+ "@ultimat3/core": "2.0.0"
34
35
  }
35
36
  }
package/src/business.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { Instant } from './instant';
7
- import { addDaysInZone, isoDateInZone, toZoned } from './zoned';
7
+ import { addDaysInZone, daysBetween, isoDateInZone, toZoned } from './zoned';
8
8
  import type { TimeZone } from './zones';
9
9
 
10
10
  /** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
@@ -68,8 +68,16 @@ export function nextBusinessDay(at: Instant, calendar: BusinessCalendar): Instan
68
68
  }
69
69
 
70
70
  /**
71
- * Business days in `[from, to)`, counted on local calendar days. Order-independent:
72
- * a reversed range returns a negative count.
71
+ * Business days in `[from, to)` — **half-open, and counted on local calendar days**: `from`'s own
72
+ * day counts, `to`'s does not, and the wall-clock time of either endpoint is not part of the
73
+ * question. It is exactly the interval `daysBetween` measures, minus the weekends and holidays
74
+ * in it, so the two functions can never disagree about how long a span is.
75
+ *
76
+ * The loop used to advance an *instant* one local day at a time and stop on
77
+ * `cursor.getTime() > end.getTime()`, which made the last day depend on whether `to`'s clock time
78
+ * had passed `from`'s: `Mon 09:00 → Fri 10:00` answered 4 and `Mon 09:00 → Fri 08:00` answered 3
79
+ * for the same calendar span. Order-independent: a reversed range returns a negative count, and an
80
+ * empty one returns `0` in either direction — never `-0`.
73
81
  */
74
82
  export function businessDaysBetween(
75
83
  from: Instant,
@@ -79,12 +87,16 @@ export function businessDaysBetween(
79
87
  const sign = to.getTime() < from.getTime() ? -1 : 1;
80
88
  const start = sign === 1 ? from : to;
81
89
  const end = sign === 1 ? to : from;
90
+ // `daysBetween` is the day count of the same half-open interval, so it is also the exact number
91
+ // of iterations — the loop cannot run away on a calendar `addDaysInZone` handles oddly.
92
+ const days = daysBetween(start, end, calendar.zone);
82
93
  let cursor = start;
83
94
  let count = 0;
84
- while (isoDateInZone(cursor, calendar.zone) !== isoDateInZone(end, calendar.zone)) {
85
- cursor = addDaysInZone(cursor, 1, calendar.zone);
86
- if (cursor.getTime() > end.getTime()) break;
95
+ for (let index = 0; index < days; index += 1) {
87
96
  if (isBusinessDay(cursor, calendar)) count += 1;
97
+ cursor = addDaysInZone(cursor, 1, calendar.zone);
88
98
  }
89
- return sign * count;
99
+ // Guarded rather than `sign * count`: an empty interval read backwards would otherwise answer
100
+ // `-0`, which `Object.is`, a `Map` key and every `toBe` treat as a value distinct from `0`.
101
+ return count === 0 ? 0 : sign * count;
90
102
  }
package/src/context.ts CHANGED
@@ -1,24 +1,25 @@
1
1
  /**
2
2
  * Resolve the request timezone once, then read it from the ALS context.
3
- * User recordheader → config default. Every formatter still takes the zone
4
- * explicitly; this is where call sites get the value from, not a hidden default.
3
+ * Explicit preferencechosenbrowser guess → config default. Every formatter still takes the
4
+ * zone explicitly; this is where call sites get the value from, not a hidden default.
5
5
  */
6
6
 
7
- import { type Ctx, useContext } from '@ultimat3/core';
8
- import { isValidTimeZone, type TimeZone, UTC } from './zones';
7
+ import { tryUseContext } from '@ultimat3/core';
8
+ import { canonicalTimeZone } from './zone-canonical';
9
+ import { type TimeZone, UTC } from './zones';
9
10
 
10
11
  /** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
11
12
  export const TIMEZONE_HEADER = 'x-timezone';
12
13
 
13
- const CTX_TIMEZONE = 'timeZone';
14
-
15
14
  export interface TimeZoneSources {
16
15
  /** `user.timeZone` — an explicit preference beats a browser guess. */
17
16
  user?: string | null;
18
- /** `x-timezone` request header. */
19
- header?: string | null;
17
+ /** The cookie a zone picker writes — chosen, so it beats what the browser was installed as. */
18
+ cookie?: string | null;
20
19
  /** `?tz=Europe/Berlin`, for share links and email previews. */
21
20
  query?: string | null;
21
+ /** `x-timezone` request header. */
22
+ header?: string | null;
22
23
  }
23
24
 
24
25
  export type TimeZoneSourceName = keyof TimeZoneSources;
@@ -34,7 +35,15 @@ export interface TimeConfig {
34
35
  order: readonly TimeZoneSourceName[];
35
36
  }
36
37
 
37
- let config: TimeConfig = { defaultZone: UTC, order: ['user', 'header', 'query'] };
38
+ /**
39
+ * Explicit before inferred, the same rule `@ultimat3/i18n`'s locale order states: `Accept-Language`
40
+ * and `x-timezone` are what the browser was installed as, while the user row, the cookie and the
41
+ * query are what a person *chose*. A zone picker that wrote a cookie the header always outranked
42
+ * would appear to do nothing.
43
+ */
44
+ const DEFAULT_ORDER: readonly TimeZoneSourceName[] = ['user', 'cookie', 'query', 'header'];
45
+
46
+ let config: TimeConfig = { defaultZone: UTC, order: DEFAULT_ORDER };
38
47
 
39
48
  export function configureTime(partial: Partial<TimeConfig>): TimeConfig {
40
49
  config = { ...config, ...partial };
@@ -46,8 +55,12 @@ export function timeConfig(): TimeConfig {
46
55
  }
47
56
 
48
57
  /**
49
- * First valid IANA name wins. An invalid value is skipped, never thrown: a stale
50
- * `x-timezone` header from an old client must not fail the request.
58
+ * First valid IANA name wins, **canonicalized**. An invalid value is skipped, never thrown: a
59
+ * stale `x-timezone` header from an old client must not fail the request.
60
+ *
61
+ * The canonical spelling is what leaves this function, because `Intl` accepts every casing:
62
+ * `x-timezone: eUrOpE/bErLiN` used to travel the whole request as its own distinct zone string,
63
+ * and every formatter cache it reached kept a permanent entry for it.
51
64
  */
52
65
  export function resolveTimeZone(
53
66
  sources: TimeZoneSources,
@@ -57,38 +70,23 @@ export function resolveTimeZone(
57
70
  for (const name of order) {
58
71
  const candidate = sources[name];
59
72
  if (candidate === undefined || candidate === null || candidate === '') continue;
60
- if (isValidTimeZone(candidate)) return { zone: candidate, source: name };
73
+ const zone = canonicalTimeZone(candidate);
74
+ if (zone !== undefined) return { zone, source: name };
61
75
  }
62
76
  return { zone: defaultZone, source: 'default' };
63
77
  }
64
78
 
65
- /** Called once per request by the HTTP layer. */
66
- export function attachTimeZone(ctx: Ctx, zone: TimeZone): TimeZone {
67
- (ctx as unknown as Record<string, unknown>)[CTX_TIMEZONE] = zone;
68
- return zone;
69
- }
70
-
71
- export function timeZoneOf(ctx: Ctx): TimeZone {
72
- return readField(ctx, CTX_TIMEZONE) ?? config.defaultZone;
73
- }
74
-
75
- /** Ambient zone for the in-flight request; the configured default outside one. */
79
+ /**
80
+ * Ambient zone for the in-flight request; the configured default outside one.
81
+ *
82
+ * The store is **`Ctx.tz`**, core's own declared field — never a second one this package writes.
83
+ * It used to be a `ctx['timeZone']` key nothing in the framework ever set, while the HTTP pipeline
84
+ * wrote `ctx.tz`: two ambient answers to one question, and the one every `@ultimat3/ui` component
85
+ * reads on a server render was the empty one, so every date rendered in UTC however the request
86
+ * arrived. `withChildContext({ tz })` and `createContext({ tz })` are therefore the only writers,
87
+ * which is what makes a subtree's zone a core concept rather than this package's.
88
+ */
76
89
  export function currentTimeZone(): TimeZone {
77
- const ctx = tryContext();
78
- return (ctx === undefined ? undefined : readField(ctx, CTX_TIMEZONE)) ?? config.defaultZone;
79
- }
80
-
81
- function tryContext(): Ctx | undefined {
82
- try {
83
- return useContext();
84
- } catch {
85
- // Outside a request scope (boot, worker tick, CLI) — fall back to the configured zone.
86
- return undefined;
87
- }
88
- }
89
-
90
- /** `Ctx` belongs to core (tier 0) and cannot import `TimeZone`; read the field structurally. */
91
- function readField(ctx: Ctx, field: string): string | undefined {
92
- const value = (ctx as unknown as Record<string, unknown>)[field];
93
- return typeof value === 'string' && value !== '' ? value : undefined;
90
+ const zone = tryUseContext()?.tz;
91
+ return zone === undefined || zone === '' ? config.defaultZone : zone;
94
92
  }
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  import { type CronExpression, parseCronOnce } from './cron-parse';
8
- import { localeInvalid } from './errors';
8
+ import { cronNotDescribable, localeInvalid } from './errors';
9
+ import { cachedFormatter } from './intl-cache';
10
+ import { canonicalLocale } from './locale-canonical';
9
11
 
10
12
  export interface CronPhrases {
11
13
  everyMinute: string;
@@ -27,15 +29,24 @@ const MAX_LISTED_TIMES = 6;
27
29
  /**
28
30
  * `describeCron('0 3 * * MON-FRI', 'en', phrases)` → `at 03:00 on Monday–Friday`.
29
31
  * Every phrase comes from the caller's `t('time.cron.*')`; only the names are `Intl`'s.
32
+ *
33
+ * A 6-field expression whose seconds field says something a 5-field one cannot is **declined**,
34
+ * not summarised: `CronPhrases` has no seconds vocabulary, so a ten-second step rendered as
35
+ * "every minute" and `30 0 3 * * *` rendered identically to `0 3 * * *`. A summary that is wrong
36
+ * is worse than one that says so, and the phrases are the caller's — adding a required field to
37
+ * `CronPhrases` breaks every existing caller to describe a schedule almost nobody writes.
30
38
  */
31
39
  export function describeCron(
32
40
  expression: string | CronExpression,
33
41
  locale: string,
34
42
  phrases: CronPhrases,
35
43
  ): string {
36
- assertLocale(locale);
44
+ // Canonicalized once, at the entry point, and `tag` is what every line below uses — `EN-us` and
45
+ // `en-US` are one locale to `Intl`, and must be one key in the caches at the foot of this file.
46
+ const tag = assertLocale(locale);
37
47
  const cron = parseCronOnce(expression);
38
- const list = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' });
48
+ if (cron.seconds.length !== 1 || cron.seconds[0] !== 0) throw cronNotDescribable(cron);
49
+ const list = new Intl.ListFormat(tag, { style: 'long', type: 'conjunction' });
39
50
  const segments: string[] = [];
40
51
 
41
52
  const minuteStep = uniformStep(cron.minutes, 60);
@@ -52,7 +63,7 @@ export function describeCron(
52
63
  segments.push(hourStep === 1 ? phrases.everyHour : fill(phrases.everyNHours, { n: hourStep }));
53
64
  } else {
54
65
  explicitTime = true;
55
- segments.push(fill(phrases.at, { time: clockTimes(cron, phrases, locale, list) }));
66
+ segments.push(fill(phrases.at, { time: clockTimes(cron, phrases, tag, list) }));
56
67
  }
57
68
 
58
69
  if (cron.dayOfMonthRestricted) {
@@ -60,11 +71,11 @@ export function describeCron(
60
71
  segments.push(fill(phrases.onDaysOfMonth, { days }));
61
72
  }
62
73
  if (cron.dayOfWeekRestricted) {
63
- const days = list.format(cron.daysOfWeek.map((day) => weekdayName(day, locale)));
74
+ const days = list.format(cron.daysOfWeek.map((day) => weekdayName(day, tag)));
64
75
  segments.push(fill(phrases.onWeekdays, { days }));
65
76
  }
66
77
  if (cron.months.length < 12) {
67
- const months = list.format(cron.months.map((month) => monthName(month, locale)));
78
+ const months = list.format(cron.months.map((month) => monthName(month, tag)));
68
79
  segments.push(fill(phrases.inMonths, { months }));
69
80
  }
70
81
  if (explicitTime && segments.length === 1) segments.push(phrases.everyDay);
@@ -89,13 +100,14 @@ function clockTimes(
89
100
  return `${shown} ${fill(phrases.andMore, { n: times.length - MAX_LISTED_TIMES })}`;
90
101
  }
91
102
 
92
- /** `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point. */
93
- function assertLocale(locale: string): void {
94
- try {
95
- Intl.DateTimeFormat.supportedLocalesOf([locale]);
96
- } catch {
97
- throw localeInvalid(locale);
98
- }
103
+ /**
104
+ * `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point — and
105
+ * hand back the canonical spelling, so validating and keying are the same single step.
106
+ */
107
+ function assertLocale(locale: string): string {
108
+ const tag = canonicalLocale(locale);
109
+ if (tag === undefined) throw localeInvalid(locale);
110
+ return tag;
99
111
  }
100
112
 
101
113
  /** Step fields: an evenly spaced set starting at 0 that covers the whole range. */
@@ -118,38 +130,33 @@ function fill(template: string, vars: Readonly<Record<string, string | number>>)
118
130
  }
119
131
 
120
132
  /**
121
- * Hard-capped rather than key-normalised, because `locale` can arrive from an Accept-Language
122
- * header: `supportedLocalesOf` collapses unknown *tags*, but still returns a distinct string for
123
- * every unknown `-u-` extension value, so only a bound keeps the key space finite. FIFO — a `Map`
124
- * iterates in insertion order and a miss costs one `Intl` construction, not a wrong answer.
133
+ * Canonically keyed **and** hard-capped, because `locale` can arrive from an Accept-Language
134
+ * header. `canonicalLocale` collapses the spellings of one locale `EN-us`, `en-latn-us` — but it
135
+ * still returns a distinct string for every unknown `-u-` extension value, so the key alone does
136
+ * not bound anything and only the cap keeps the key space finite. Neither half is redundant. The
137
+ * cap and its FIFO live in `intl-cache.ts`, because `zones.ts` and `format.ts` needed the same
138
+ * rule and a hazard documented in one file is a hazard the other two repeat.
139
+ *
140
+ * Both caches are fed the canonical `tag` by `describeCron` alone, never a caller string.
125
141
  */
126
- const MAX_CACHED_LOCALES = 32;
127
142
  const monthFormatters = new Map<string, Intl.DateTimeFormat>();
128
143
  const weekdayFormatters = new Map<string, Intl.DateTimeFormat>();
129
144
 
130
- function formatterFor(
131
- cache: Map<string, Intl.DateTimeFormat>,
132
- locale: string,
133
- options: Intl.DateTimeFormatOptions,
134
- ): Intl.DateTimeFormat {
135
- const hit = cache.get(locale);
136
- if (hit !== undefined) return hit;
137
- const formatter = new Intl.DateTimeFormat(locale, options);
138
- if (cache.size >= MAX_CACHED_LOCALES) {
139
- const oldest = cache.keys().next().value;
140
- if (oldest !== undefined) cache.delete(oldest);
141
- }
142
- cache.set(locale, formatter);
143
- return formatter;
144
- }
145
-
146
145
  function monthName(month: number, locale: string): string {
147
- const formatter = formatterFor(monthFormatters, locale, { month: 'long', timeZone: 'UTC' });
146
+ const formatter = cachedFormatter(
147
+ monthFormatters,
148
+ locale,
149
+ () => new Intl.DateTimeFormat(locale, { month: 'long', timeZone: 'UTC' }),
150
+ );
148
151
  return formatter.format(new Date(Date.UTC(2026, month - 1, 1)));
149
152
  }
150
153
 
151
154
  function weekdayName(isoDay: number, locale: string): string {
152
- const formatter = formatterFor(weekdayFormatters, locale, { weekday: 'long', timeZone: 'UTC' });
155
+ const formatter = cachedFormatter(
156
+ weekdayFormatters,
157
+ locale,
158
+ () => new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }),
159
+ );
153
160
  // 2026-06-01 is a Monday, so ISO day N is that date + (N - 1).
154
161
  return formatter.format(new Date(Date.UTC(2026, 5, isoDay)));
155
162
  }
package/src/cron-parse.ts CHANGED
@@ -141,9 +141,14 @@ function parseField(
141
141
  }
142
142
 
143
143
  if (from > to) {
144
- // Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom.
145
- for (let value = from; value <= max; value += step) values.add(value);
146
- for (let value = min; value <= to; value += step) values.add(value);
144
+ // Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom, and the stride CONTINUES across
145
+ // the wrap: `23-3/2` is 23, 01, 03 every second hour starting at 23. Restarting at `min`
146
+ // answered 23, 00, 02, an hour off for every occurrence past midnight.
147
+ const span = max - min + 1;
148
+ const length = to - from + span;
149
+ for (let offset = 0; offset <= length; offset += step) {
150
+ values.add(min + ((from - min + offset) % span));
151
+ }
147
152
  } else {
148
153
  for (let value = from; value <= to; value += step) values.add(value);
149
154
  }
package/src/errors.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * DST ambiguity is a real state of the world, so it gets a code instead of a guess.
4
4
  */
5
5
 
6
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+ import { registerErrorCodes, renderCauseValue, UltimateError } from '@ultimat3/core';
7
7
 
8
8
  export const TIME_ERROR_CODES = [
9
9
  'X_TIMEZONE_INVALID',
@@ -14,6 +14,7 @@ export const TIME_ERROR_CODES = [
14
14
  'X_INSTANT_INVALID',
15
15
  'X_SCHEDULE_INVALID',
16
16
  'X_LOCALE_INVALID',
17
+ 'X_CRON_NOT_DESCRIBABLE',
17
18
  ] as const;
18
19
 
19
20
  export type TimeErrorCode = (typeof TIME_ERROR_CODES)[number];
@@ -27,6 +28,7 @@ export const TIME_ERROR_TITLES: Readonly<Record<TimeErrorCode, string>> = {
27
28
  X_INSTANT_INVALID: 'not a parseable instant',
28
29
  X_SCHEDULE_INVALID: 'a wall-clock field is out of range',
29
30
  X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
31
+ X_CRON_NOT_DESCRIBABLE: 'a valid cron expression describeCron has no vocabulary for',
30
32
  };
31
33
 
32
34
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -54,7 +56,9 @@ export class TimeError extends UltimateError {
54
56
  export function scheduleInvalid(field: string, value: unknown, range: string): TimeError {
55
57
  return new TimeError({
56
58
  code: 'X_SCHEDULE_INVALID',
57
- cause: `${field} must be ${range}, got ${String(value)}`,
59
+ // `value` is whatever a caller put in a `LocalSlot` — this factory is exported, so it is a form
60
+ // field or a config value as often as it is the `number` the in-package caller passes.
61
+ cause: `${field} must be ${range}, got ${renderCauseValue(value)}`,
58
62
  fix: `pass an integer in ${range} for ${field} — wall-clock fields are not wrapped or clamped, because a silently shifted schedule is worse than a failed one`,
59
63
  });
60
64
  }
@@ -75,6 +79,22 @@ export function cronInvalid(expression: string, reason: string): TimeError {
75
79
  });
76
80
  }
77
81
 
82
+ /**
83
+ * The expression parses and schedules correctly — `describeCron` just cannot put it into words.
84
+ * Separate from `X_CRON_INVALID`, which is a typo: this one is a valid schedule, and telling the
85
+ * caller to fix their cron would be telling them to break a working task.
86
+ */
87
+ export function cronNotDescribable(cron: {
88
+ source: string;
89
+ seconds: readonly number[];
90
+ }): TimeError {
91
+ return new TimeError({
92
+ code: 'X_CRON_NOT_DESCRIBABLE',
93
+ cause: `cron "${cron.source}" fires on second ${cron.seconds.join(',')}, and CronPhrases has no seconds vocabulary`,
94
+ fix: `render the real runs instead of a summary — nextCronOccurrences('${cron.source}', zone, from, 3) — or, if second-level precision is not wanted, describe the 5-field schedule describeCron('${cron.source.split(/\s+/).slice(1).join(' ')}', locale, phrases)`,
95
+ });
96
+ }
97
+
78
98
  export function durationInvalid(input: string): TimeError {
79
99
  return new TimeError({
80
100
  code: 'X_DURATION_INVALID',
package/src/format.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import { differenceMs, type Instant } from './instant';
8
+ import { cachedFormatter } from './intl-cache';
9
+ import { canonicalLocale } from './locale-canonical';
8
10
  import { assertTimeZone, type TimeZone } from './zones';
9
11
 
10
12
  export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
@@ -165,11 +167,17 @@ export function ordinal(value: number, locale = 'en'): string {
165
167
 
166
168
  const cache = new Map<string, Intl.DateTimeFormat>();
167
169
 
170
+ /**
171
+ * Bounded, and keyed on a zone `assertTimeZone` and a locale `canonicalLocale` have both already
172
+ * canonicalized — `Accept-Language` sends `EN-us` and `en-US` for one locale, and each spelling
173
+ * used to mint its own permanent entry. The bound stays: an unknown `-u-` extension value survives
174
+ * canonicalization as a distinct string, so only the cap keeps this key space finite.
175
+ *
176
+ * A tag `Intl` cannot parse falls through unchanged, so the `Intl.DateTimeFormat` constructor
177
+ * still raises it — this seam decides a cache key, never whether a locale is acceptable.
178
+ */
168
179
  function formatterFor(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
169
- const key = `${locale}|${JSON.stringify(options)}`;
170
- const cached = cache.get(key);
171
- if (cached !== undefined) return cached;
172
- const formatter = new Intl.DateTimeFormat(locale, options);
173
- cache.set(key, formatter);
174
- return formatter;
180
+ const tag = canonicalLocale(locale) ?? locale;
181
+ const key = `${tag}|${JSON.stringify(options)}`;
182
+ return cachedFormatter(cache, key, () => new Intl.DateTimeFormat(tag, options));
175
183
  }
package/src/index.ts CHANGED
@@ -14,7 +14,6 @@ export {
14
14
  WEEKEND_SUN_ONLY,
15
15
  } from './business';
16
16
  export {
17
- attachTimeZone,
18
17
  configureTime,
19
18
  currentTimeZone,
20
19
  resolveTimeZone,
@@ -24,7 +23,6 @@ export {
24
23
  type TimeZoneSourceName,
25
24
  type TimeZoneSources,
26
25
  timeConfig,
27
- timeZoneOf,
28
26
  } from './context';
29
27
  export {
30
28
  type CronExpression,
@@ -54,6 +52,7 @@ export {
54
52
  } from './duration';
55
53
  export {
56
54
  cronInvalid,
55
+ cronNotDescribable,
57
56
  dstAmbiguous,
58
57
  dstNonexistent,
59
58
  durationInvalid,
@@ -83,7 +82,7 @@ export {
83
82
  addMs,
84
83
  compareInstants,
85
84
  differenceMs,
86
- EPOCH,
85
+ epoch,
87
86
  fromEpochMs,
88
87
  fromEpochSeconds,
89
88
  fromIso,
@@ -105,6 +104,11 @@ export {
105
104
  nextWeeklySlot,
106
105
  type WeeklySlot,
107
106
  } from './schedule';
107
+ /**
108
+ * `canonicalTimeZone` is public because a zone arriving from a request header has to become one
109
+ * key before it reaches anything that caches on it — `@ultimat3/http` reads `x-timezone`.
110
+ */
111
+ export { canonicalTimeZone } from './zone-canonical';
108
112
  export {
109
113
  addDaysInZone,
110
114
  daysBetween,
package/src/instant.ts CHANGED
@@ -12,10 +12,16 @@ declare const instantBrand: unique symbol;
12
12
  /** A `Date` that has been proven valid and is documented as UTC. */
13
13
  export type Instant = Date & { readonly [instantBrand]: 'utc' };
14
14
 
15
- /** Wrap a `Date` from an untrusted source (a DB driver, a parsed payload). */
15
+ /**
16
+ * Wrap a `Date` from an untrusted source (a DB driver, a parsed payload).
17
+ *
18
+ * A **copy**, never the caller's own object: `value as Instant` handed back a `Date` the caller
19
+ * still holds and can `setTime()` after the brand is applied, so a value this function certified
20
+ * as valid could stop being the value it certified.
21
+ */
16
22
  export function instant(value: Date): Instant {
17
23
  if (Number.isNaN(value.getTime())) throw instantInvalid(String(value));
18
- return value as Instant;
24
+ return new Date(value.getTime()) as Instant;
19
25
  }
20
26
 
21
27
  /** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is a bug. */
@@ -86,7 +92,18 @@ export function isInstant(value: unknown): value is Instant {
86
92
  return value instanceof Date && !Number.isNaN(value.getTime());
87
93
  }
88
94
 
89
- export const EPOCH: Instant = new Date(0) as Instant;
95
+ /**
96
+ * A fresh instant at the Unix epoch, per call.
97
+ *
98
+ * Replaces the `EPOCH` constant, which was one shared mutable `Date` exported from a tier-1
99
+ * package: a single `EPOCH.setUTCFullYear(...)` anywhere in the process corrupted it for every
100
+ * other consumer, permanently and silently. A `Date` cannot be frozen — `Object.freeze` does not
101
+ * close `setTime`, because the value lives in an internal slot — so the only safe shape is a
102
+ * function. **Breaking: `EPOCH` is removed**; it had no callers in this repo.
103
+ */
104
+ export function epoch(): Instant {
105
+ return new Date(0) as Instant;
106
+ }
90
107
 
91
108
  /** Tolerates a `Clock` whose `now()` returns either a `Date` or epoch milliseconds. */
92
109
  function epochMsOf(value: Date | number): number {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * One bounded cache for every `Intl` formatter this package builds.
3
+ * A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on that
4
+ * string is memory the client chooses: 4,096 case-variants of one zone name retained 31 MB,
5
+ * ~7.7 KB per `Intl.DateTimeFormat`, and 600 zones times 2^12 casings has no ceiling at all.
6
+ */
7
+
8
+ /**
9
+ * Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
10
+ * and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
11
+ * `Intl` construction, never a wrong answer — which is what makes a bound safe here at all.
12
+ */
13
+ export const MAX_CACHED_FORMATTERS = 512;
14
+
15
+ /** FIFO — a `Map` iterates in insertion order, so the first key inserted is the first evicted. */
16
+ export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: () => T): T {
17
+ const hit = cache.get(key);
18
+ if (hit !== undefined) return hit;
19
+ const formatter = build();
20
+ if (cache.size >= MAX_CACHED_FORMATTERS) {
21
+ const oldest = cache.keys().next().value;
22
+ if (oldest !== undefined) cache.delete(oldest);
23
+ }
24
+ cache.set(key, formatter);
25
+ return formatter;
26
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * One locale, one key. `Intl` accepts `EN-us`, `en-US` and `en-latn-us` as the same locale, so a
3
+ * cache keyed on the caller's spelling holds three formatters where one would do — and the caller
4
+ * is `Accept-Language`. The twin of `zone-canonical.ts`, for the other header-supplied string.
5
+ */
6
+
7
+ /**
8
+ * The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
9
+ * (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
10
+ * falls back for it, and refusing here would be stricter than the formatters this feeds.
11
+ *
12
+ * Deliberately **not** memoised: this is string work, and a `Map` keyed on a header value is the
13
+ * unbounded cache the whole `intl-cache.ts` bound exists to prevent.
14
+ */
15
+ export function canonicalLocale(locale: string): string | undefined {
16
+ try {
17
+ // `getCanonicalLocales` runs the same IsStructurallyValidLanguageTag check that
18
+ // `supportedLocalesOf` throws on, and unlike it, hands back the canonical spelling.
19
+ return Intl.getCanonicalLocales(locale)[0];
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
package/src/schedule.ts CHANGED
@@ -25,9 +25,9 @@ export interface LocalSlot {
25
25
  * gap, `{ gap: 'next' }` picks the first existing local time instead of skipping the day.
26
26
  */
27
27
  /** Wall-clock fields are never wrapped or clamped — a shifted schedule beats no schedule. */
28
- function assertWallField(field: string, value: number, max: number): void {
29
- if (!Number.isInteger(value) || value < 0 || value > max) {
30
- throw scheduleInvalid(field, value, `an integer 0-${String(max)}`);
28
+ function assertWallField(field: string, value: number, max: number, min = 0): void {
29
+ if (!Number.isInteger(value) || value < min || value > max) {
30
+ throw scheduleInvalid(field, value, `an integer ${String(min)}-${String(max)}`);
31
31
  }
32
32
  }
33
33
 
@@ -80,11 +80,16 @@ export interface WeeklySlot extends LocalSlot {
80
80
 
81
81
  /** Next `weekday` at the local time, strictly after `after`. */
82
82
  export function nextWeeklySlot(slot: WeeklySlot, after: Instant): Instant {
83
+ // Checked BEFORE the search, not after it. Falling out of the loop reported X_TIMEZONE_INVALID
84
+ // naming a zone that is perfectly valid, with a fix line about IANA identifiers that fixes
85
+ // nothing — the field the caller got wrong is `weekday`, and an error has to say so.
86
+ assertWallField('slot.weekday', slot.weekday, 7, 1);
83
87
  let cursor = after;
84
88
  for (let index = 0; index < 8; index += 1) {
85
89
  const candidate = nextLocalSlot(slot, cursor);
86
90
  if (toZoned(candidate, slot.zone).weekday === slot.weekday) return candidate;
87
91
  cursor = candidate;
88
92
  }
93
+ // Seven local days always contain every ISO weekday; reaching here means the zone data is broken.
89
94
  throw timezoneInvalid(slot.zone);
90
95
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One IANA zone, one key. `Intl` accepts every casing of a zone name, so `Europe/Berlin` and
3
+ * `eUrOpE/bErLiN` reach a formatter cache — and every downstream comparison — as two zones.
4
+ * A 13-letter name has 2^12 casings and a request header can name any of them.
5
+ */
6
+
7
+ import { cachedFormatter } from './intl-cache';
8
+
9
+ /** ES2024 `Intl` accepts `+01:00` as a zone; we do not — a fixed offset has no DST rules. */
10
+ const NUMERIC_OFFSET = /^[+-]/;
11
+
12
+ /**
13
+ * Lowercase → canonical, for every zone the runtime lists. Built once, ~445 entries, and it is
14
+ * what makes the common case — a casing of a real zone — cost a string lookup instead of an
15
+ * `Intl.DateTimeFormat` construction the caller can mint at will.
16
+ */
17
+ let listed: Map<string, string> | undefined;
18
+
19
+ function listedZones(): Map<string, string> {
20
+ if (listed !== undefined) return listed;
21
+ const table = new Map<string, string>();
22
+ for (const zone of Intl.supportedValuesOf('timeZone')) table.set(zone.toLowerCase(), zone);
23
+ listed = table;
24
+ return table;
25
+ }
26
+
27
+ /**
28
+ * Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) and the runtime's extras (`EST`, `GMT`) are
29
+ * not in the listed set, so they take the `Intl` probe once — bounded for the same reason every
30
+ * other cache here is.
31
+ */
32
+ const probed = new Map<string, string | ''>();
33
+
34
+ /**
35
+ * The canonical spelling of an IANA zone, or `undefined` for anything that is not one.
36
+ * `'CET'` and `'+01:00'` are not zones: an abbreviation is ambiguous and an offset has no rules.
37
+ */
38
+ export function canonicalTimeZone(zone: string): string | undefined {
39
+ if (zone === '' || NUMERIC_OFFSET.test(zone)) return undefined;
40
+ const known = listedZones().get(zone.toLowerCase());
41
+ if (known !== undefined) return known;
42
+ // `''` is the cached "not a zone" answer — a `Map` miss and a cached refusal must not look the
43
+ // same, or every invalid header re-probes `Intl` forever.
44
+ const resolved = cachedFormatter(probed, zone, () => resolve(zone));
45
+ return resolved === '' ? undefined : resolved;
46
+ }
47
+
48
+ function resolve(zone: string): string | '' {
49
+ try {
50
+ return new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone;
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
package/src/zones.ts CHANGED
@@ -6,15 +6,14 @@
6
6
 
7
7
  import { timezoneInvalid } from './errors';
8
8
  import type { Instant } from './instant';
9
+ import { cachedFormatter } from './intl-cache';
10
+ import { canonicalTimeZone } from './zone-canonical';
9
11
 
10
12
  /** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
11
13
  export type TimeZone = string;
12
14
 
13
15
  export const UTC: TimeZone = 'UTC';
14
16
 
15
- /** ES2024 `Intl` accepts `+01:00` as a zone; we do not — a fixed offset has no DST rules. */
16
- const NUMERIC_OFFSET = /^[+-]/;
17
-
18
17
  /**
19
18
  * The package's only builder of a UTC epoch from calendar fields, because `Date.UTC` remaps
20
19
  * years 0–99 onto 1900–1999 — silently, so a first-century wall clock resolves 1900 years off
@@ -36,18 +35,18 @@ export function utcEpoch(
36
35
  }
37
36
 
38
37
  export function isValidTimeZone(zone: string): boolean {
39
- if (zone === '' || NUMERIC_OFFSET.test(zone)) return false;
40
- try {
41
- new Intl.DateTimeFormat('en-US', { timeZone: zone });
42
- return true;
43
- } catch {
44
- return false;
45
- }
38
+ return canonicalTimeZone(zone) !== undefined;
46
39
  }
47
40
 
41
+ /**
42
+ * The **canonical** spelling, not the caller's. `Intl` answers for every casing of a zone name, so
43
+ * returning the input let one zone travel the process as many strings — each one its own key in
44
+ * every formatter cache downstream, and unbounded when the string came from a request header.
45
+ */
48
46
  export function assertTimeZone(zone: string): TimeZone {
49
- if (!isValidTimeZone(zone)) throw timezoneInvalid(zone);
50
- return zone;
47
+ const canonical = canonicalTimeZone(zone);
48
+ if (canonical === undefined) throw timezoneInvalid(zone);
49
+ return canonical;
51
50
  }
52
51
 
53
52
  /** Wall-clock fields of an instant in a zone, seconds precision. */
@@ -127,13 +126,20 @@ export function zoneAbbrev(
127
126
  return label ?? offsetLabel(offsetAt(zone, at));
128
127
  }
129
128
 
130
- /** True when the zone observes a different offset at some point in the surrounding year. */
129
+ /**
130
+ * True when the zone observes a different offset at some point in the surrounding year.
131
+ *
132
+ * Twelve probes on the FIRST of twelve consecutive months. `setUTCMonth(+n)` rolls over at month
133
+ * end — from 31 January the twelve probes land in January, March, March, May, May, July, July,
134
+ * August, October, October, December, December, so February, April, June, September and November
135
+ * are never asked, and a zone whose only transition falls in one of them reads as DST-free.
136
+ */
131
137
  export function observesDst(zone: TimeZone, at: Instant): boolean {
138
+ const start = zonePartsAt(zone, at);
132
139
  const offsets = new Set<number>();
133
140
  for (let month = 0; month < 12; month += 1) {
134
- const probe = new Date(at.getTime());
135
- probe.setUTCMonth(probe.getUTCMonth() + month);
136
- offsets.add(offsetAt(zone, probe as Instant));
141
+ const probe = new Date(utcEpoch(start.year, start.month + month, 1, 12)) as Instant;
142
+ offsets.add(offsetAt(zone, probe));
137
143
  }
138
144
  return offsets.size > 1;
139
145
  }
@@ -141,25 +147,24 @@ export function observesDst(zone: TimeZone, at: Instant): boolean {
141
147
  const formatters = new Map<string, Intl.DateTimeFormat>();
142
148
 
143
149
  function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
144
- const cached = formatters.get(zone);
145
- if (cached !== undefined) return cached;
146
- let formatter: Intl.DateTimeFormat;
147
- try {
148
- formatter = new Intl.DateTimeFormat('en-US', {
149
- timeZone: zone,
150
- hourCycle: 'h23',
151
- year: 'numeric',
152
- month: '2-digit',
153
- day: '2-digit',
154
- hour: '2-digit',
155
- minute: '2-digit',
156
- second: '2-digit',
157
- });
158
- } catch {
159
- throw timezoneInvalid(zone);
160
- }
161
- formatters.set(zone, formatter);
162
- return formatter;
150
+ // Keyed on the canonical name, so 4,096 casings of one zone are one entry rather than 4,096.
151
+ const canonical = canonicalTimeZone(zone);
152
+ if (canonical === undefined) throw timezoneInvalid(zone);
153
+ return cachedFormatter(
154
+ formatters,
155
+ canonical,
156
+ () =>
157
+ new Intl.DateTimeFormat('en-US', {
158
+ timeZone: canonical,
159
+ hourCycle: 'h23',
160
+ year: 'numeric',
161
+ month: '2-digit',
162
+ day: '2-digit',
163
+ hour: '2-digit',
164
+ minute: '2-digit',
165
+ second: '2-digit',
166
+ }),
167
+ );
163
168
  }
164
169
 
165
170
  function pad2(value: number): string {