@ultimat3/time 1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/package.json +35 -0
  4. package/src/business.d.ts +37 -0
  5. package/src/business.d.ts.map +1 -0
  6. package/src/business.js +68 -0
  7. package/src/business.js.map +1 -0
  8. package/src/business.ts +90 -0
  9. package/src/context.d.ts +40 -0
  10. package/src/context.d.ts.map +1 -0
  11. package/src/context.js +61 -0
  12. package/src/context.js.map +1 -0
  13. package/src/context.ts +94 -0
  14. package/src/cron-describe.ts +159 -0
  15. package/src/cron-occurrence.ts +203 -0
  16. package/src/cron-parse.ts +193 -0
  17. package/src/cron.d.ts +60 -0
  18. package/src/cron.d.ts.map +1 -0
  19. package/src/cron.js +390 -0
  20. package/src/cron.js.map +1 -0
  21. package/src/cron.ts +16 -0
  22. package/src/duration.d.ts +32 -0
  23. package/src/duration.d.ts.map +1 -0
  24. package/src/duration.js +134 -0
  25. package/src/duration.js.map +1 -0
  26. package/src/duration.ts +156 -0
  27. package/src/errors.d.ts +26 -0
  28. package/src/errors.d.ts.map +1 -0
  29. package/src/errors.js +78 -0
  30. package/src/errors.js.map +1 -0
  31. package/src/errors.ts +121 -0
  32. package/src/format.d.ts +54 -0
  33. package/src/format.d.ts.map +1 -0
  34. package/src/format.js +133 -0
  35. package/src/format.js.map +1 -0
  36. package/src/format.ts +175 -0
  37. package/src/index.d.ts +12 -0
  38. package/src/index.d.ts.map +1 -0
  39. package/src/index.js +12 -0
  40. package/src/index.js.map +1 -0
  41. package/src/index.ts +137 -0
  42. package/src/instant.d.ts +38 -0
  43. package/src/instant.d.ts.map +1 -0
  44. package/src/instant.js +76 -0
  45. package/src/instant.js.map +1 -0
  46. package/src/instant.ts +94 -0
  47. package/src/schedule.d.ts +24 -0
  48. package/src/schedule.d.ts.map +1 -0
  49. package/src/schedule.js +67 -0
  50. package/src/schedule.js.map +1 -0
  51. package/src/schedule.ts +90 -0
  52. package/src/zoned.d.ts +80 -0
  53. package/src/zoned.d.ts.map +1 -0
  54. package/src/zoned.js +146 -0
  55. package/src/zoned.js.map +1 -0
  56. package/src/zoned.ts +268 -0
  57. package/src/zones.d.ts +40 -0
  58. package/src/zones.d.ts.map +1 -0
  59. package/src/zones.js +116 -0
  60. package/src/zones.js.map +1 -0
  61. package/src/zones.ts +167 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # 🕒 @ultimat3/time
2
+
3
+ **Golden rule: store UTC, format at the edge, always with an explicit IANA zone.**
4
+ An `Instant` is a point on the UTC timeline with no zone attached. Zones exist only where
5
+ a human reads the value. No formatter in this package has an ambient default zone, and
6
+ none ever will — "the server's timezone" is not an answer to "what time is it for the user".
7
+
8
+ | Concern | Store | Format / compute |
9
+ |---|---|---|
10
+ | Instants | UTC (`timestamptz`, ISO-8601 `Z`) | `Intl.DateTimeFormat` + explicit `timeZone` |
11
+ | Wall clock | never | `toZoned` / `fromZoned` with a DST policy |
12
+ | Zone | on the user record | resolved once per request into the ALS context |
13
+ | Durations | milliseconds | `parseDuration('2h30m')`, `formatDuration(ms, locale)` |
14
+ | Schedules | cron + IANA zone | `nextCronOccurrence`, `nextLocalSlot` |
15
+
16
+ No `date-fns-tz`, no tzdata table. Zone math is derived from `Intl.DateTimeFormat` +
17
+ `formatToParts`, which is exact in Bun and always current with the runtime's tzdata.
18
+
19
+ ## Use
20
+
21
+ ```ts
22
+ import { formatWithOffset, fromIso, nextLocalSlot, now, toZoned } from '@ultimat3/time';
23
+
24
+ const at = fromIso('2026-03-14T08:00:00Z');
25
+ formatWithOffset(at, { locale: 'en-GB', zone: 'Europe/Berlin' });
26
+ // "14 Mar 2026, 09:00 (GMT+1)"
27
+ formatWithOffset(at, { locale: 'en-GB', zone: 'America/New_York' });
28
+ // "14 Mar 2026, 04:00 (GMT-4)"
29
+
30
+ nextLocalSlot({ zone: 'Asia/Kathmandu', hour: 9 }, now(clock)); // 09:00 local, +05:45 handled
31
+ ```
32
+
33
+ ## DST is a policy, not a guess
34
+
35
+ `fromZoned(wall, zone, { gap, overlap })` is the function everything else is built on.
36
+
37
+ | Case | What happens | Policy |
38
+ |---|---|---|
39
+ | Normal | one instant matches the wall clock | — |
40
+ | **Gap** (spring forward) | `02:30` never happens | `'next'` (default), `'previous'`, `'throw'` |
41
+ | **Overlap** (fall back) | `01:30` happens twice | `'first'` (default), `'second'`, `'throw'` |
42
+
43
+ It works by search-and-verify: read the wall-clock fields as if they were UTC, subtract
44
+ each candidate offset in force around that moment, then convert each candidate *back* and
45
+ keep the ones that reproduce the requested wall clock. Two survivors means overlap, none
46
+ means gap. `fromZonedDetailed()` returns which case it was — log it when scheduling.
47
+
48
+ Half-hour and 45-minute zones (`Asia/Kolkata`, `Asia/Kathmandu`, `Australia/Adelaide`,
49
+ `Pacific/Chatham`) are ordinary cases here, not special ones.
50
+
51
+ ## Calendar days
52
+
53
+ **Never cross a day boundary with `86_400_000`.** A local day is 23, 24 or 25 hours long. Every
54
+ one of these takes the zone explicitly, and none of them has a default.
55
+
56
+ | Function | Answers |
57
+ |---|---|
58
+ | `startOfDay(at, zone)` / `endOfDay(at, zone)` | local midnight; the last millisecond of the day |
59
+ | `addDaysInZone(at, days, zone)` | the same wall-clock time, `days` calendar days away |
60
+ | `daysBetween(from, to, zone)` | local day boundaries crossed — signed, always integral |
61
+ | `isoDateInZone(at, zone)` | `2026-03-14` |
62
+ | `isSameLocalDay(left, right, zone)` | whether two instants share a local date |
63
+
64
+ `daysBetween` counts boundaries, not milliseconds: 23 real hours across spring forward is `1`,
65
+ and 24 real hours inside a 25-hour fall-back day is `0`.
66
+
67
+ ## Cron
68
+
69
+ `parseCron` handles 5 or 6 fields, `*/n`, ranges, lists, named months and days, `@daily`
70
+ macros, and Vixie's dom/dow OR rule. `nextCronOccurrence(expr, zone, after)` iterates the
71
+ zone's **wall clock**, so `0 3 * * *` in `Europe/Berlin` stays at 03:00 local across a DST
72
+ change, and a job scheduled inside the gap runs at the first existing local time instead of
73
+ being skipped. `describeCron(expr, locale, phrases)` renders the dashboard summary — month and
74
+ weekday names from `Intl`, every connective word **required** from the caller's `t('time.cron.*')`,
75
+ 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.
77
+
78
+ ## Business days
79
+
80
+ The weekend is configuration. `WEEKEND_SAT_SUN`, `WEEKEND_FRI_SAT` (much of the Gulf),
81
+ `WEEKEND_SUN_ONLY`, plus a holiday list of local `YYYY-MM-DD` dates.
82
+
83
+ ## Errors
84
+
85
+ | Code | When |
86
+ |---|---|
87
+ | `X_TIMEZONE_INVALID` | not an IANA name (abbreviations and numeric offsets are rejected) |
88
+ | `X_CRON_INVALID` | unparseable expression, or one that can never match |
89
+ | `X_DURATION_INVALID` | `'3'` with no unit, trailing junk, unknown unit |
90
+ | `X_DST_AMBIGUOUS` | overlap hit with `overlap: 'throw'` |
91
+ | `X_DST_NONEXISTENT` | gap hit with `gap: 'throw'` |
92
+ | `X_INSTANT_INVALID` | unparseable timestamp |
93
+ | `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached `describeCron` |
94
+
95
+ ## Why it exists
96
+
97
+ Naive time math breaks quietly: the digest goes out an hour early for half the year, the
98
+ reminder never fires on the spring-forward day, and nobody notices until a customer does.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@ultimat3/time",
3
+ "version": "1.0.0",
4
+ "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/time"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/core": "1.0.0"
34
+ }
35
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Business-day math. The weekend is configuration, not a constant: Friday/Saturday in
3
+ * much of the Gulf, Sunday-only in parts of Asia, Saturday/Sunday in the West.
4
+ */
5
+ import type { Instant } from './instant';
6
+ import type { TimeZone } from './zones';
7
+ /** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
8
+ export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
9
+ export declare const WEEKEND_SAT_SUN: readonly IsoWeekday[];
10
+ /** Gulf states, and the default in Israel (Fri/Sat). */
11
+ export declare const WEEKEND_FRI_SAT: readonly IsoWeekday[];
12
+ export declare const WEEKEND_SUN_ONLY: readonly IsoWeekday[];
13
+ export interface BusinessCalendar {
14
+ zone: TimeZone;
15
+ /** Defaults to Saturday + Sunday, which is a choice, not a law. */
16
+ weekendDays?: readonly IsoWeekday[];
17
+ /** Local `YYYY-MM-DD` dates that are holidays in this calendar. */
18
+ holidays?: readonly string[];
19
+ }
20
+ export declare function isWeekend(at: Instant, zone: TimeZone, weekendDays?: readonly IsoWeekday[]): boolean;
21
+ export declare function isHoliday(at: Instant, calendar: BusinessCalendar): boolean;
22
+ /** A business day is a non-weekend, non-holiday local day. */
23
+ export declare function isBusinessDay(at: Instant, calendar: BusinessCalendar): boolean;
24
+ /**
25
+ * Move `days` business days forward (or back, if negative), keeping the local wall-clock
26
+ * time. `days === 0` returns the input untouched, even on a weekend — callers that want
27
+ * "the next business day" should ask for 1.
28
+ */
29
+ export declare function addBusinessDays(at: Instant, days: number, calendar: BusinessCalendar): Instant;
30
+ /** The next business day at the same local time; today if it already qualifies. */
31
+ export declare function nextBusinessDay(at: Instant, calendar: BusinessCalendar): Instant;
32
+ /**
33
+ * Business days in `[from, to)`, counted on local calendar days. Order-independent:
34
+ * a reversed range returns a negative count.
35
+ */
36
+ export declare function businessDaysBetween(from: Instant, to: Instant, calendar: BusinessCalendar): number;
37
+ //# sourceMappingURL=business.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"business.d.ts","sourceRoot":"","sources":["business.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAExC,oDAAoD;AACpD,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAEnD,eAAO,MAAM,eAAe,EAAE,SAAS,UAAU,EAAW,CAAC;AAC7D,wDAAwD;AACxD,eAAO,MAAM,eAAe,EAAE,SAAS,UAAU,EAAW,CAAC;AAC7D,eAAO,MAAM,gBAAgB,EAAE,SAAS,UAAU,EAAQ,CAAC;AAE3D,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,QAAQ,CAAC;IACf,mEAAmE;IACnE,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IACpC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,wBAAkB,GAAG,OAAO,CAE7F;AAED,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAG1E;AAED,8DAA8D;AAC9D,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAK9E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAe9F;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAEhF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,OAAO,EACb,EAAE,EAAE,OAAO,EACX,QAAQ,EAAE,gBAAgB,GACzB,MAAM,CAYR"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Business-day math. The weekend is configuration, not a constant: Friday/Saturday in
3
+ * much of the Gulf, Sunday-only in parts of Asia, Saturday/Sunday in the West.
4
+ */
5
+ import { addDaysInZone, isoDateInZone, toZoned } from './zoned';
6
+ export const WEEKEND_SAT_SUN = [6, 7];
7
+ /** Gulf states, and the default in Israel (Fri/Sat). */
8
+ export const WEEKEND_FRI_SAT = [5, 6];
9
+ export const WEEKEND_SUN_ONLY = [7];
10
+ export function isWeekend(at, zone, weekendDays = WEEKEND_SAT_SUN) {
11
+ return weekendDays.includes(toZoned(at, zone).weekday);
12
+ }
13
+ export function isHoliday(at, calendar) {
14
+ const holidays = calendar.holidays ?? [];
15
+ return holidays.includes(isoDateInZone(at, calendar.zone));
16
+ }
17
+ /** A business day is a non-weekend, non-holiday local day. */
18
+ export function isBusinessDay(at, calendar) {
19
+ return (!isWeekend(at, calendar.zone, calendar.weekendDays ?? WEEKEND_SAT_SUN) &&
20
+ !isHoliday(at, calendar));
21
+ }
22
+ /**
23
+ * Move `days` business days forward (or back, if negative), keeping the local wall-clock
24
+ * time. `days === 0` returns the input untouched, even on a weekend — callers that want
25
+ * "the next business day" should ask for 1.
26
+ */
27
+ export function addBusinessDays(at, days, calendar) {
28
+ if (days === 0)
29
+ return at;
30
+ const step = days > 0 ? 1 : -1;
31
+ let remaining = Math.abs(days);
32
+ let cursor = at;
33
+ let guard = 0;
34
+ while (remaining > 0) {
35
+ cursor = addDaysInZone(cursor, step, calendar.zone);
36
+ if (isBusinessDay(cursor, calendar))
37
+ remaining -= 1;
38
+ guard += 1;
39
+ // A calendar with every day marked as a holiday would otherwise loop forever.
40
+ if (guard > Math.abs(days) * 7 + 3650)
41
+ return cursor;
42
+ }
43
+ return cursor;
44
+ }
45
+ /** The next business day at the same local time; today if it already qualifies. */
46
+ export function nextBusinessDay(at, calendar) {
47
+ return isBusinessDay(at, calendar) ? at : addBusinessDays(at, 1, calendar);
48
+ }
49
+ /**
50
+ * Business days in `[from, to)`, counted on local calendar days. Order-independent:
51
+ * a reversed range returns a negative count.
52
+ */
53
+ export function businessDaysBetween(from, to, calendar) {
54
+ const sign = to.getTime() < from.getTime() ? -1 : 1;
55
+ const start = sign === 1 ? from : to;
56
+ const end = sign === 1 ? to : from;
57
+ let cursor = start;
58
+ let count = 0;
59
+ while (isoDateInZone(cursor, calendar.zone) !== isoDateInZone(end, calendar.zone)) {
60
+ cursor = addDaysInZone(cursor, 1, calendar.zone);
61
+ if (cursor.getTime() > end.getTime())
62
+ break;
63
+ if (isBusinessDay(cursor, calendar))
64
+ count += 1;
65
+ }
66
+ return sign * count;
67
+ }
68
+ //# sourceMappingURL=business.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"business.js","sourceRoot":"","sources":["business.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Business-day math. The weekend is configuration, not a constant: Friday/Saturday in
3
+ * much of the Gulf, Sunday-only in parts of Asia, Saturday/Sunday in the West.
4
+ */
5
+
6
+ import type { Instant } from './instant';
7
+ import { addDaysInZone, isoDateInZone, toZoned } from './zoned';
8
+ import type { TimeZone } from './zones';
9
+
10
+ /** ISO weekday numbers: 1 = Monday … 7 = Sunday. */
11
+ export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
12
+
13
+ export const WEEKEND_SAT_SUN: readonly IsoWeekday[] = [6, 7];
14
+ /** Gulf states, and the default in Israel (Fri/Sat). */
15
+ export const WEEKEND_FRI_SAT: readonly IsoWeekday[] = [5, 6];
16
+ export const WEEKEND_SUN_ONLY: readonly IsoWeekday[] = [7];
17
+
18
+ export interface BusinessCalendar {
19
+ zone: TimeZone;
20
+ /** Defaults to Saturday + Sunday, which is a choice, not a law. */
21
+ weekendDays?: readonly IsoWeekday[];
22
+ /** Local `YYYY-MM-DD` dates that are holidays in this calendar. */
23
+ holidays?: readonly string[];
24
+ }
25
+
26
+ export function isWeekend(at: Instant, zone: TimeZone, weekendDays = WEEKEND_SAT_SUN): boolean {
27
+ return weekendDays.includes(toZoned(at, zone).weekday as IsoWeekday);
28
+ }
29
+
30
+ export function isHoliday(at: Instant, calendar: BusinessCalendar): boolean {
31
+ const holidays = calendar.holidays ?? [];
32
+ return holidays.includes(isoDateInZone(at, calendar.zone));
33
+ }
34
+
35
+ /** A business day is a non-weekend, non-holiday local day. */
36
+ export function isBusinessDay(at: Instant, calendar: BusinessCalendar): boolean {
37
+ return (
38
+ !isWeekend(at, calendar.zone, calendar.weekendDays ?? WEEKEND_SAT_SUN) &&
39
+ !isHoliday(at, calendar)
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Move `days` business days forward (or back, if negative), keeping the local wall-clock
45
+ * time. `days === 0` returns the input untouched, even on a weekend — callers that want
46
+ * "the next business day" should ask for 1.
47
+ */
48
+ export function addBusinessDays(at: Instant, days: number, calendar: BusinessCalendar): Instant {
49
+ if (days === 0) return at;
50
+ const step = days > 0 ? 1 : -1;
51
+ let remaining = Math.abs(days);
52
+ let cursor = at;
53
+ let guard = 0;
54
+
55
+ while (remaining > 0) {
56
+ cursor = addDaysInZone(cursor, step, calendar.zone);
57
+ if (isBusinessDay(cursor, calendar)) remaining -= 1;
58
+ guard += 1;
59
+ // A calendar with every day marked as a holiday would otherwise loop forever.
60
+ if (guard > Math.abs(days) * 7 + 3650) return cursor;
61
+ }
62
+ return cursor;
63
+ }
64
+
65
+ /** The next business day at the same local time; today if it already qualifies. */
66
+ export function nextBusinessDay(at: Instant, calendar: BusinessCalendar): Instant {
67
+ return isBusinessDay(at, calendar) ? at : addBusinessDays(at, 1, calendar);
68
+ }
69
+
70
+ /**
71
+ * Business days in `[from, to)`, counted on local calendar days. Order-independent:
72
+ * a reversed range returns a negative count.
73
+ */
74
+ export function businessDaysBetween(
75
+ from: Instant,
76
+ to: Instant,
77
+ calendar: BusinessCalendar,
78
+ ): number {
79
+ const sign = to.getTime() < from.getTime() ? -1 : 1;
80
+ const start = sign === 1 ? from : to;
81
+ const end = sign === 1 ? to : from;
82
+ let cursor = start;
83
+ 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;
87
+ if (isBusinessDay(cursor, calendar)) count += 1;
88
+ }
89
+ return sign * count;
90
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Resolve the request timezone once, then read it from the ALS context.
3
+ * User record → header → config default. Every formatter still takes the zone
4
+ * explicitly; this is where call sites get the value from, not a hidden default.
5
+ */
6
+ import { type Ctx } from '@ultimat3/core';
7
+ import { type TimeZone } from './zones';
8
+ /** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
9
+ export declare const TIMEZONE_HEADER = "x-timezone";
10
+ export interface TimeZoneSources {
11
+ /** `user.timeZone` — an explicit preference beats a browser guess. */
12
+ user?: string | null;
13
+ /** `x-timezone` request header. */
14
+ header?: string | null;
15
+ /** `?tz=Europe/Berlin`, for share links and email previews. */
16
+ query?: string | null;
17
+ }
18
+ export type TimeZoneSourceName = keyof TimeZoneSources;
19
+ export interface TimeZoneResolution {
20
+ zone: TimeZone;
21
+ source: TimeZoneSourceName | 'default';
22
+ }
23
+ export interface TimeConfig {
24
+ /** The zone used when nothing else resolves. UTC unless the app says otherwise. */
25
+ defaultZone: TimeZone;
26
+ order: readonly TimeZoneSourceName[];
27
+ }
28
+ export declare function configureTime(partial: Partial<TimeConfig>): TimeConfig;
29
+ export declare function timeConfig(): TimeConfig;
30
+ /**
31
+ * First valid IANA name wins. An invalid value is skipped, never thrown: a stale
32
+ * `x-timezone` header from an old client must not fail the request.
33
+ */
34
+ export declare function resolveTimeZone(sources: TimeZoneSources, overrides?: Partial<TimeConfig>): TimeZoneResolution;
35
+ /** Called once per request by the HTTP layer. */
36
+ export declare function attachTimeZone(ctx: Ctx, zone: TimeZone): TimeZone;
37
+ export declare function timeZoneOf(ctx: Ctx): TimeZone;
38
+ /** Ambient zone for the in-flight request; the configured default outside one. */
39
+ export declare function currentTimeZone(): TimeZone;
40
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["context.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,GAAG,EAAc,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAmB,KAAK,QAAQ,EAAO,MAAM,SAAS,CAAC;AAE9D,oFAAoF;AACpF,eAAO,MAAM,eAAe,eAAe,CAAC;AAI5C,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,mCAAmC;IACnC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,eAAe,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,EAAE,kBAAkB,GAAG,SAAS,CAAC;CACxC;AAED,MAAM,WAAW,UAAU;IACzB,mFAAmF;IACnF,WAAW,EAAE,QAAQ,CAAC;IACtB,KAAK,EAAE,SAAS,kBAAkB,EAAE,CAAC;CACtC;AAID,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,UAAU,CAGtE;AAED,wBAAgB,UAAU,IAAI,UAAU,CAEvC;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,eAAe,EACxB,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAClC,kBAAkB,CAQpB;AAED,iDAAiD;AACjD,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAGjE;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,QAAQ,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,eAAe,IAAI,QAAQ,CAG1C"}
package/src/context.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Resolve the request timezone once, then read it from the ALS context.
3
+ * User record → header → config default. Every formatter still takes the zone
4
+ * explicitly; this is where call sites get the value from, not a hidden default.
5
+ */
6
+ import { useContext } from '@ultimat3/core';
7
+ import { isValidTimeZone, UTC } from './zones';
8
+ /** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
9
+ export const TIMEZONE_HEADER = 'x-timezone';
10
+ const CTX_TIMEZONE = 'timeZone';
11
+ let config = { defaultZone: UTC, order: ['user', 'header', 'query'] };
12
+ export function configureTime(partial) {
13
+ config = { ...config, ...partial };
14
+ return config;
15
+ }
16
+ export function timeConfig() {
17
+ return config;
18
+ }
19
+ /**
20
+ * First valid IANA name wins. An invalid value is skipped, never thrown: a stale
21
+ * `x-timezone` header from an old client must not fail the request.
22
+ */
23
+ export function resolveTimeZone(sources, overrides = {}) {
24
+ const { defaultZone, order } = { ...config, ...overrides };
25
+ for (const name of order) {
26
+ const candidate = sources[name];
27
+ if (candidate === undefined || candidate === null || candidate === '')
28
+ continue;
29
+ if (isValidTimeZone(candidate))
30
+ return { zone: candidate, source: name };
31
+ }
32
+ return { zone: defaultZone, source: 'default' };
33
+ }
34
+ /** Called once per request by the HTTP layer. */
35
+ export function attachTimeZone(ctx, zone) {
36
+ ctx[CTX_TIMEZONE] = zone;
37
+ return zone;
38
+ }
39
+ export function timeZoneOf(ctx) {
40
+ return readField(ctx, CTX_TIMEZONE) ?? config.defaultZone;
41
+ }
42
+ /** Ambient zone for the in-flight request; the configured default outside one. */
43
+ export function currentTimeZone() {
44
+ const ctx = tryContext();
45
+ return (ctx === undefined ? undefined : readField(ctx, CTX_TIMEZONE)) ?? config.defaultZone;
46
+ }
47
+ function tryContext() {
48
+ try {
49
+ return useContext();
50
+ }
51
+ catch {
52
+ // Outside a request scope (boot, worker tick, CLI) — fall back to the configured zone.
53
+ return undefined;
54
+ }
55
+ }
56
+ /** `Ctx` belongs to core (tier 0) and cannot import `TimeZone`; read the field structurally. */
57
+ function readField(ctx, field) {
58
+ const value = ctx[field];
59
+ return typeof value === 'string' && value !== '' ? value : undefined;
60
+ }
61
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["context.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAY,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,eAAe,EAAiB,GAAG,EAAE,MAAM,SAAS,CAAC;AAE9D,oFAAoF;AACpF,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC;AAE5C,MAAM,YAAY,GAAG,UAAU,CAAC;AAwBhC,IAAI,MAAM,GAAe,EAAE,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,CAAC;AAElF,MAAM,UAAU,aAAa,CAAC,OAA4B;IACxD,MAAM,GAAG,EAAE,GAAG,MAAM,EAAE,GAAG,OAAO,EAAE,CAAC;IACnC,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC7B,OAAwB,EACxB,SAAS,GAAwB,EAAE;IAEnC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,CAAC;IAC3D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,SAAS,KAAK,SAAS,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,EAAE;YAAE,SAAS;QAChF,IAAI,eAAe,CAAC,SAAS,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IAC3E,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AAClD,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,cAAc,CAAC,GAAQ,EAAE,IAAc;IACpD,GAA0C,CAAC,YAAY,CAAC,GAAG,IAAI,CAAC;IACjE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,GAAQ;IACjC,OAAO,SAAS,CAAC,GAAG,EAAE,YAAY,CAAC,IAAI,MAAM,CAAC,WAAW,CAAC;AAC5D,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,eAAe;IAC7B,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC,IAAI,MAAM,CAAC,WAAW,CAAC;AAC9F,CAAC;AAED,SAAS,UAAU;IACjB,IAAI,CAAC;QACH,OAAO,UAAU,EAAE,CAAC;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,uFAAuF;QACvF,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,gGAAgG;AAChG,SAAS,SAAS,CAAC,GAAQ,EAAE,KAAa;IACxC,MAAM,KAAK,GAAI,GAA0C,CAAC,KAAK,CAAC,CAAC;IACjE,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACvE,CAAC"}
package/src/context.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Resolve the request timezone once, then read it from the ALS context.
3
+ * User record → header → config default. Every formatter still takes the zone
4
+ * explicitly; this is where call sites get the value from, not a hidden default.
5
+ */
6
+
7
+ import { type Ctx, useContext } from '@ultimat3/core';
8
+ import { isValidTimeZone, type TimeZone, UTC } from './zones';
9
+
10
+ /** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
11
+ export const TIMEZONE_HEADER = 'x-timezone';
12
+
13
+ const CTX_TIMEZONE = 'timeZone';
14
+
15
+ export interface TimeZoneSources {
16
+ /** `user.timeZone` — an explicit preference beats a browser guess. */
17
+ user?: string | null;
18
+ /** `x-timezone` request header. */
19
+ header?: string | null;
20
+ /** `?tz=Europe/Berlin`, for share links and email previews. */
21
+ query?: string | null;
22
+ }
23
+
24
+ export type TimeZoneSourceName = keyof TimeZoneSources;
25
+
26
+ export interface TimeZoneResolution {
27
+ zone: TimeZone;
28
+ source: TimeZoneSourceName | 'default';
29
+ }
30
+
31
+ export interface TimeConfig {
32
+ /** The zone used when nothing else resolves. UTC unless the app says otherwise. */
33
+ defaultZone: TimeZone;
34
+ order: readonly TimeZoneSourceName[];
35
+ }
36
+
37
+ let config: TimeConfig = { defaultZone: UTC, order: ['user', 'header', 'query'] };
38
+
39
+ export function configureTime(partial: Partial<TimeConfig>): TimeConfig {
40
+ config = { ...config, ...partial };
41
+ return config;
42
+ }
43
+
44
+ export function timeConfig(): TimeConfig {
45
+ return config;
46
+ }
47
+
48
+ /**
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.
51
+ */
52
+ export function resolveTimeZone(
53
+ sources: TimeZoneSources,
54
+ overrides: Partial<TimeConfig> = {},
55
+ ): TimeZoneResolution {
56
+ const { defaultZone, order } = { ...config, ...overrides };
57
+ for (const name of order) {
58
+ const candidate = sources[name];
59
+ if (candidate === undefined || candidate === null || candidate === '') continue;
60
+ if (isValidTimeZone(candidate)) return { zone: candidate, source: name };
61
+ }
62
+ return { zone: defaultZone, source: 'default' };
63
+ }
64
+
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. */
76
+ 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;
94
+ }