@bgord/tools 0.15.1 → 0.16.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 (122) hide show
  1. package/dist/basename.vo.d.ts +11 -2
  2. package/dist/basename.vo.js +22 -13
  3. package/dist/date-calculator.service.d.ts +2 -2
  4. package/dist/date-calculator.service.js +10 -11
  5. package/dist/date-range.vo.d.ts +1 -0
  6. package/dist/date-range.vo.js +2 -1
  7. package/dist/day-iso-id.vo.d.ts +3 -0
  8. package/dist/day-iso-id.vo.js +4 -4
  9. package/dist/day.vo.js +12 -10
  10. package/dist/directory-path-absolute.vo.d.ts +5 -0
  11. package/dist/directory-path-absolute.vo.js +11 -5
  12. package/dist/directory-path-relative.vo.d.ts +6 -0
  13. package/dist/directory-path-relative.vo.js +12 -6
  14. package/dist/dll.service.js +37 -31
  15. package/dist/extension.vo.d.ts +6 -2
  16. package/dist/extension.vo.js +11 -9
  17. package/dist/file-path-absolute-schema.vo.d.ts +5 -0
  18. package/dist/file-path-absolute-schema.vo.js +12 -6
  19. package/dist/file-path-relative-schema.vo.d.ts +6 -1
  20. package/dist/file-path-relative-schema.vo.js +10 -6
  21. package/dist/file-path.vo.js +4 -4
  22. package/dist/filename-from-string.vo.d.ts +6 -4
  23. package/dist/filename-from-string.vo.js +15 -14
  24. package/dist/filename-suffix.vo.d.ts +4 -2
  25. package/dist/filename-suffix.vo.js +5 -3
  26. package/dist/filename.vo.d.ts +2 -2
  27. package/dist/filename.vo.js +9 -9
  28. package/dist/height.vo.d.ts +6 -4
  29. package/dist/height.vo.js +56 -51
  30. package/dist/index.d.ts +0 -1
  31. package/dist/index.js +0 -1
  32. package/dist/language.vo.d.ts +1 -1
  33. package/dist/language.vo.js +1 -2
  34. package/dist/mime.vo.d.ts +3 -1
  35. package/dist/mime.vo.js +8 -6
  36. package/dist/month-iso-id.vo.d.ts +3 -0
  37. package/dist/month-iso-id.vo.js +7 -12
  38. package/dist/month.vo.js +15 -13
  39. package/dist/object-key.vo.d.ts +5 -0
  40. package/dist/object-key.vo.js +16 -6
  41. package/dist/package-version.vo.d.ts +3 -0
  42. package/dist/package-version.vo.js +12 -34
  43. package/dist/pagination.service.d.ts +1 -1
  44. package/dist/pagination.service.js +11 -11
  45. package/dist/quarter-iso-id.vo.d.ts +3 -0
  46. package/dist/quarter-iso-id.vo.js +8 -7
  47. package/dist/rate-limiter.service.d.ts +3 -2
  48. package/dist/rate-limiter.service.js +4 -2
  49. package/dist/reordering.service.d.ts +20 -2
  50. package/dist/reordering.service.js +49 -29
  51. package/dist/revision.vo.d.ts +8 -3
  52. package/dist/revision.vo.js +13 -6
  53. package/dist/rounding.adapter.js +1 -2
  54. package/dist/size.vo.d.ts +1 -0
  55. package/dist/size.vo.js +4 -7
  56. package/dist/streak-calculator.service.d.ts +3 -4
  57. package/dist/streak-calculator.service.js +11 -17
  58. package/dist/time-zone-offset-value.vo.d.ts +1 -1
  59. package/dist/time-zone-offset-value.vo.js +1 -7
  60. package/dist/time.service.d.ts +11 -6
  61. package/dist/time.service.js +31 -18
  62. package/dist/timezone.vo.js +1 -3
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/dist/week-iso-id.vo.d.ts +3 -0
  65. package/dist/week-iso-id.vo.js +4 -4
  66. package/dist/week.vo.js +1 -1
  67. package/dist/weekday.vo.d.ts +7 -6
  68. package/dist/weekday.vo.js +20 -13
  69. package/dist/weight.vo.d.ts +12 -0
  70. package/dist/weight.vo.js +37 -27
  71. package/dist/year-iso-id.vo.d.ts +3 -0
  72. package/dist/year-iso-id.vo.js +4 -6
  73. package/dist/year.vo.d.ts +2 -0
  74. package/dist/year.vo.js +4 -2
  75. package/package.json +1 -1
  76. package/readme.md +0 -1
  77. package/src/basename.vo.ts +25 -14
  78. package/src/clock.vo.ts +1 -0
  79. package/src/date-calculator.service.ts +10 -15
  80. package/src/date-range.vo.ts +3 -1
  81. package/src/day-iso-id.vo.ts +9 -10
  82. package/src/day.vo.ts +17 -10
  83. package/src/directory-path-absolute.vo.ts +12 -5
  84. package/src/directory-path-relative.vo.ts +13 -6
  85. package/src/dll.service.ts +45 -43
  86. package/src/extension.vo.ts +14 -12
  87. package/src/file-path-absolute-schema.vo.ts +15 -6
  88. package/src/file-path-relative-schema.vo.ts +13 -6
  89. package/src/file-path.vo.ts +15 -11
  90. package/src/filename-from-string.vo.ts +20 -15
  91. package/src/filename-suffix.vo.ts +8 -4
  92. package/src/filename.vo.ts +14 -15
  93. package/src/height.vo.ts +71 -53
  94. package/src/index.ts +0 -1
  95. package/src/language.vo.ts +1 -2
  96. package/src/mime.vo.ts +10 -7
  97. package/src/month-iso-id.vo.ts +10 -20
  98. package/src/month.vo.ts +19 -13
  99. package/src/object-key.vo.ts +21 -7
  100. package/src/outlier-detector.service.ts +1 -0
  101. package/src/package-version.vo.ts +18 -47
  102. package/src/pagination.service.ts +15 -13
  103. package/src/quarter-iso-id.vo.ts +11 -13
  104. package/src/quarter.vo.ts +3 -0
  105. package/src/rate-limiter.service.ts +7 -7
  106. package/src/reordering.service.ts +52 -38
  107. package/src/revision.vo.ts +17 -8
  108. package/src/rounding.adapter.ts +1 -3
  109. package/src/size.vo.ts +6 -16
  110. package/src/streak-calculator.service.ts +12 -17
  111. package/src/time-zone-offset-value.vo.ts +2 -7
  112. package/src/time.service.ts +43 -45
  113. package/src/timezone.vo.ts +1 -3
  114. package/src/week-iso-id.vo.ts +13 -14
  115. package/src/week.vo.ts +4 -2
  116. package/src/weekday.vo.ts +27 -13
  117. package/src/weight.vo.ts +49 -30
  118. package/src/year-iso-id.vo.ts +6 -9
  119. package/src/year.vo.ts +12 -2
  120. package/dist/stepper.service.d.ts +0 -23
  121. package/dist/stepper.service.js +0 -33
  122. package/src/stepper.service.ts +0 -43
@@ -3,6 +3,18 @@ export declare enum WeightUnit {
3
3
  kg = "kg",
4
4
  lb = "lb"
5
5
  }
6
+ export declare const WeightNonFiniteError: {
7
+ readonly error: "weight.non_finite";
8
+ };
9
+ export declare const WeightNegativeError: {
10
+ readonly error: "weight.negative";
11
+ };
12
+ export declare const WeightNonPositiveError: {
13
+ readonly error: "weight.non_positive";
14
+ };
15
+ export declare const WeightGramsNonNegativeError: {
16
+ readonly error: "weight.grams_non_negative";
17
+ };
6
18
  export declare class Weight {
7
19
  private readonly grams;
8
20
  private static readonly GRAMS_PER_KILOGRAM;
package/dist/weight.vo.js CHANGED
@@ -1,16 +1,26 @@
1
1
  import { z } from "zod/v4";
2
2
  import { RoundToDecimal } from "./rounding.adapter";
3
- const FiniteNumericValue = z.number().refine(Number.isFinite, { message: "Expected a finite number" });
4
- const NonNegativeNumericValue = FiniteNumericValue.min(0, { message: "Must be greater than or equal to 0" });
5
- const PositiveNumericValue = FiniteNumericValue.gt(0, { message: "Must be greater than 0" });
6
- const NonNegativeIntegerGrams = FiniteNumericValue.int().min(0, {
7
- message: "Grams must be an integer greater than or equal to 0",
8
- });
9
3
  export var WeightUnit;
10
4
  (function (WeightUnit) {
11
5
  WeightUnit["kg"] = "kg";
12
6
  WeightUnit["lb"] = "lb";
13
7
  })(WeightUnit || (WeightUnit = {}));
8
+ export const WeightNonFiniteError = { error: "weight.non_finite" };
9
+ export const WeightNegativeError = { error: "weight.negative" };
10
+ export const WeightNonPositiveError = { error: "weight.non_positive" };
11
+ export const WeightGramsNonNegativeError = { error: "weight.grams_non_negative" };
12
+ const WeightQuantityNumber = z
13
+ .number(WeightNonFiniteError)
14
+ .refine(Number.isFinite, WeightNonFiniteError)
15
+ .min(0, WeightNegativeError);
16
+ const DivisionScalarNumber = z
17
+ .number(WeightNonFiniteError)
18
+ .refine(Number.isFinite, WeightNonFiniteError)
19
+ .gt(0, WeightNonPositiveError);
20
+ const CanonicalGramsInteger = z
21
+ .number(WeightGramsNonNegativeError)
22
+ .int(WeightGramsNonNegativeError)
23
+ .min(0, WeightGramsNonNegativeError);
14
24
  export class Weight {
15
25
  grams;
16
26
  static GRAMS_PER_KILOGRAM = 1_000;
@@ -20,22 +30,22 @@ export class Weight {
20
30
  this.grams = grams;
21
31
  }
22
32
  static fromKilograms(kilograms) {
23
- NonNegativeNumericValue.parse(kilograms);
24
- const gramsRounded = Math.round(kilograms * Weight.GRAMS_PER_KILOGRAM);
25
- NonNegativeIntegerGrams.parse(gramsRounded);
26
- return new Weight(gramsRounded);
33
+ const kilogramsParsed = WeightQuantityNumber.parse(kilograms);
34
+ const gramsRounded = Math.round(kilogramsParsed * Weight.GRAMS_PER_KILOGRAM);
35
+ const grams = CanonicalGramsInteger.parse(gramsRounded);
36
+ return new Weight(grams);
27
37
  }
28
38
  static fromPounds(pounds) {
29
- NonNegativeNumericValue.parse(pounds);
30
- const gramsRounded = Math.round(pounds * Weight.KILOGRAMS_PER_POUND * Weight.GRAMS_PER_KILOGRAM);
31
- NonNegativeIntegerGrams.parse(gramsRounded);
32
- return new Weight(gramsRounded);
39
+ const poundsParsed = WeightQuantityNumber.parse(pounds);
40
+ const gramsRounded = Math.round(poundsParsed * Weight.KILOGRAMS_PER_POUND * Weight.GRAMS_PER_KILOGRAM);
41
+ const grams = CanonicalGramsInteger.parse(gramsRounded);
42
+ return new Weight(grams);
33
43
  }
34
44
  static fromGrams(grams) {
35
- NonNegativeNumericValue.parse(grams);
36
- const gramsRounded = Math.round(grams);
37
- NonNegativeIntegerGrams.parse(gramsRounded);
38
- return new Weight(gramsRounded);
45
+ const gramsParsed = WeightQuantityNumber.parse(grams);
46
+ const gramsRounded = Math.round(gramsParsed);
47
+ const integerGrams = CanonicalGramsInteger.parse(gramsRounded);
48
+ return new Weight(integerGrams);
39
49
  }
40
50
  static zero() {
41
51
  return new Weight(0);
@@ -52,7 +62,7 @@ export class Weight {
52
62
  return rounding ? rounding.round(pounds) : pounds;
53
63
  }
54
64
  format(unit, rounding = new RoundToDecimal(2)) {
55
- const value = { [WeightUnit.kg]: this.toKilograms(rounding), [WeightUnit.lb]: this.toPounds(rounding) }[unit];
65
+ const value = unit === WeightUnit.kg ? this.toKilograms(rounding) : this.toPounds(rounding);
56
66
  return `${value.toString()} ${unit}`;
57
67
  }
58
68
  add(other) {
@@ -63,16 +73,16 @@ export class Weight {
63
73
  return new Weight(result < 0 ? 0 : result);
64
74
  }
65
75
  multiply(factor) {
66
- NonNegativeNumericValue.parse(factor);
67
- const gramsRounded = Math.round(this.grams * factor);
68
- NonNegativeIntegerGrams.parse(gramsRounded);
69
- return new Weight(gramsRounded);
76
+ const factorParsed = WeightQuantityNumber.parse(factor);
77
+ const gramsRounded = Math.round(this.grams * factorParsed);
78
+ const grams = CanonicalGramsInteger.parse(gramsRounded);
79
+ return new Weight(grams);
70
80
  }
71
81
  divideByScalar(divisor) {
72
- PositiveNumericValue.parse(divisor);
73
- const gramsRounded = Math.round(this.grams / divisor);
74
- NonNegativeIntegerGrams.parse(gramsRounded);
75
- return new Weight(gramsRounded);
82
+ const divisorParsed = DivisionScalarNumber.parse(divisor);
83
+ const gramsRounded = Math.round(this.grams / divisorParsed);
84
+ const grams = CanonicalGramsInteger.parse(gramsRounded);
85
+ return new Weight(grams);
76
86
  }
77
87
  equals(other) {
78
88
  return this.grams === other.grams;
@@ -1,3 +1,6 @@
1
1
  import { z } from "zod/v4";
2
+ export declare const YearIsoIdError: {
3
+ readonly error: "year-iso-id.invalid";
4
+ };
2
5
  export declare const YearIsoId: z.ZodString;
3
6
  export type YearIsoIdType = z.infer<typeof YearIsoId>;
@@ -1,8 +1,6 @@
1
1
  import { z } from "zod/v4";
2
+ export const YearIsoIdError = { error: "year-iso-id.invalid" };
2
3
  export const YearIsoId = z
3
- .string()
4
- .regex(/^\d{4}$/)
5
- .refine((value) => {
6
- const year = Number(value);
7
- return value.length === 4 && Number.isInteger(year);
8
- }, { message: "year-iso-id.invalid" });
4
+ .string(YearIsoIdError)
5
+ .regex(/^\d{4}$/, YearIsoIdError)
6
+ .refine((value) => Number.isInteger(Number(value)), YearIsoIdError);
package/dist/year.vo.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { DateRange } from "./date-range.vo";
2
2
  import { type TimestampType } from "./timestamp.vo";
3
3
  import { type YearIsoIdType } from "./year-iso-id.vo";
4
+ export declare const YearInvalidIntegerError: "year.invalid_integer";
5
+ export declare const YearOutOfRangeError: "year.out_of_range";
4
6
  export declare class Year extends DateRange {
5
7
  toIsoId(): YearIsoIdType;
6
8
  isLeapYear(): boolean;
package/dist/year.vo.js CHANGED
@@ -2,6 +2,8 @@ import { addYears, endOfYear, getYear, startOfYear } from "date-fns";
2
2
  import { DateRange } from "./date-range.vo";
3
3
  import { Timestamp } from "./timestamp.vo";
4
4
  import { YearIsoId } from "./year-iso-id.vo";
5
+ export const YearInvalidIntegerError = "year.invalid_integer";
6
+ export const YearOutOfRangeError = "year.out_of_range";
5
7
  export class Year extends DateRange {
6
8
  toIsoId() {
7
9
  return String(getYear(this.getStart()));
@@ -32,9 +34,9 @@ export class Year extends DateRange {
32
34
  }
33
35
  static fromNumber(value) {
34
36
  if (!Number.isInteger(value))
35
- throw new Error("year.invalid_integer");
37
+ throw new Error(YearInvalidIntegerError);
36
38
  if (value < 0 || value > 9999)
37
- throw new Error("year.out_of_range");
39
+ throw new Error(YearOutOfRangeError);
38
40
  const reference = Timestamp.parse(Date.UTC(value, 0, 1, 0, 0, 0, 0));
39
41
  return Year.fromTimestamp(reference);
40
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bgord/tools",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Bartosz Gordon",
package/readme.md CHANGED
@@ -81,7 +81,6 @@ src/
81
81
  ├── rounding.port.ts
82
82
  ├── simple-linear-regression.service.ts
83
83
  ├── size.vo.ts
84
- ├── stepper.service.ts
85
84
  ├── stopwatch.service.ts
86
85
  ├── streak-calculator.service.ts
87
86
  ├── sum.service.ts
@@ -1,18 +1,29 @@
1
1
  import { z } from "zod/v4";
2
2
 
3
- export const BasenameSchema = z
4
- .string()
3
+ export const BasenameTypeError = "basename.not.string" as const;
4
+ export const BasenameEmptyError = "basename.empty" as const;
5
+ export const BasenameTooLongError = "basename.too.long" as const;
6
+ export const BasenameSlashesForbiddenError = "basename.slashes.forbidden" as const;
7
+ export const BasenameControlCharsForbiddenError = "basename.control.chars.forbidden" as const;
8
+ export const BasenameDotSegmentsForbiddenError = "basename.dot.segments.forbidden" as const;
9
+ export const BasenameDotfilesForbiddenError = "basename.dotfiles.forbidden" as const;
10
+ export const BasenameTrailingDotForbiddenError = "basename.trailing.dot.forbidden" as const;
11
+ export const BasenameBadCharsError = "basename.bad.chars" as const;
12
+
13
+ export const Basename = z
14
+ .string(BasenameTypeError)
5
15
  .trim()
6
- .min(1, "basename_empty")
7
- .max(128, "basename_too_long")
8
- .refine((s) => !/[/\\]/.test(s), "basename_slashes_forbidden")
16
+ .min(1, BasenameEmptyError)
17
+ .max(128, BasenameTooLongError)
18
+ .refine((s) => !/[/\\]/.test(s), BasenameSlashesForbiddenError)
19
+ // dot-related checks: dot-segments first for specific errors…
9
20
  // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
10
- .refine((s) => !/[\u0000-\u001F\u007F]/.test(s), "basename_control_chars_forbidden")
11
- // check dot-segments FIRST so "." / ".." get the intended error
12
- .refine((s) => s !== "." && s !== "..", "basename_dot_segments_forbidden")
13
- // then disallow any other dotfile (".env", ".gitignore", etc.)
14
- .refine((s) => !s.startsWith("."), "basename_dotfiles_forbidden")
15
- .refine((s) => !s.endsWith("."), "basename_trailing_dot_forbidden")
16
- .regex(/^[A-Za-z0-9._-]+$/, "basename_bad_chars")
17
- .brand("basename");
18
- export type BasenameType = z.infer<typeof BasenameSchema>;
21
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), BasenameControlCharsForbiddenError)
22
+ .refine((value) => value !== "." && value !== "..", BasenameDotSegmentsForbiddenError)
23
+ // …then any other dotfile
24
+ .refine((value) => !value.startsWith("."), BasenameDotfilesForbiddenError)
25
+ .refine((value) => !value.endsWith("."), BasenameTrailingDotForbiddenError)
26
+ .regex(/^[A-Za-z0-9._-]+$/, BasenameBadCharsError)
27
+ .brand("Basename");
28
+
29
+ export type BasenameType = z.infer<typeof Basename>;
package/src/clock.vo.ts CHANGED
@@ -17,6 +17,7 @@ export class Clock {
17
17
  static fromEpochMs(timestamp: TimestampType, formatter?: ClockFormatter): Clock {
18
18
  const hour = Hour.fromEpochMs(timestamp);
19
19
  const minute = Minute.fromEpochMs(timestamp);
20
+
20
21
  return new Clock(hour, minute, formatter);
21
22
  }
22
23
 
@@ -1,26 +1,21 @@
1
1
  import { Time } from "./time.service";
2
- import type { TimestampType } from "./timestamp.vo";
2
+ import { Timestamp, type TimestampType } from "./timestamp.vo";
3
3
 
4
4
  type GetStartOfDayTsInTzConfigType = { now: TimestampType; timeZoneOffsetMs: number };
5
5
 
6
6
  export class DateCalculator {
7
- static getStartOfDayTsInTz(config: GetStartOfDayTsInTzConfigType) {
8
- const startOfDayUTC = new Date();
9
- startOfDayUTC.setUTCHours(0, 0, 0, 0);
7
+ static getStartOfDayTsInTz(config: GetStartOfDayTsInTzConfigType): TimestampType {
8
+ const dayMs = Time.Days(1).ms;
10
9
 
11
- const startOfDayInTimeZone = startOfDayUTC.getTime() + config.timeZoneOffsetMs;
10
+ // UTC midnight for the UTC date of `now`
11
+ const utcMidnight = Math.floor(config.now / dayMs) * dayMs;
12
12
 
13
- const timeSinceNewDayInTimeZoneRelativeToUtcStartOfDay =
14
- (config.now - startOfDayInTimeZone) % Time.Days(1).ms;
13
+ // Candidate start of the local day (in UTC), anchored to the same UTC date
14
+ let start = utcMidnight + config.timeZoneOffsetMs;
15
15
 
16
- if (timeSinceNewDayInTimeZoneRelativeToUtcStartOfDay >= Time.Days(1).ms) {
17
- return config.now - timeSinceNewDayInTimeZoneRelativeToUtcStartOfDay + Time.Days(1).ms;
18
- }
16
+ // If the candidate is in the future relative to `now`, it means local midnight was "yesterday" in UTC.
17
+ if (start > config.now) start -= dayMs;
19
18
 
20
- if (timeSinceNewDayInTimeZoneRelativeToUtcStartOfDay >= 0) {
21
- return config.now - timeSinceNewDayInTimeZoneRelativeToUtcStartOfDay;
22
- }
23
-
24
- return config.now - timeSinceNewDayInTimeZoneRelativeToUtcStartOfDay - Time.Days(1).ms;
19
+ return Timestamp.parse(start);
25
20
  }
26
21
  }
@@ -1,11 +1,13 @@
1
1
  import type { TimestampType } from "./timestamp.vo";
2
2
 
3
+ export const DateRangeInvalidError = "invalid.date.range" as const;
4
+
3
5
  export class DateRange {
4
6
  constructor(
5
7
  private readonly start: TimestampType,
6
8
  private readonly end: TimestampType,
7
9
  ) {
8
- if (start > end) throw new Error("Invalid date range");
10
+ if (start > end) throw new Error(DateRangeInvalidError);
9
11
  }
10
12
 
11
13
  getStart(): TimestampType {
@@ -1,16 +1,15 @@
1
1
  import { isValid, parseISO } from "date-fns";
2
2
  import { z } from "zod/v4";
3
3
 
4
+ export const DayIsoIdError = { error: "invalid.day.iso.id" } as const;
5
+
4
6
  export const DayIsoId = z
5
- .string()
6
- // 4-digit year, 2-digit month, 2-digit day
7
- .regex(/^\d{4}-\d{2}-\d{2}$/)
8
- .refine(
9
- (value) => {
10
- const date = parseISO(value);
11
- return isValid(date) && value === date.toISOString().slice(0, 10);
12
- },
13
- { message: "day-iso-id.invalid" },
14
- );
7
+ .string(DayIsoIdError)
8
+ .regex(/^\d{4}-\d{2}-\d{2}$/, DayIsoIdError)
9
+ .refine((value) => {
10
+ const date = parseISO(value);
11
+
12
+ return isValid(date) && value === date.toISOString().slice(0, 10);
13
+ }, DayIsoIdError);
15
14
 
16
15
  export type DayIsoIdType = z.infer<typeof DayIsoId>;
package/src/day.vo.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { addDays, endOfDay, startOfDay } from "date-fns";
2
1
  import { DateRange } from "./date-range.vo";
3
2
  import { DayIsoId, type DayIsoIdType } from "./day-iso-id.vo";
4
3
  import { Time } from "./time.service";
@@ -10,28 +9,35 @@ export class Day extends DateRange {
10
9
  }
11
10
 
12
11
  toIsoId(): DayIsoIdType {
13
- return new Date(this.getStart() + Time.Hours(12).ms).toISOString().slice(0, 10) as DayIsoIdType;
12
+ const midday = this.getStart() + Time.Hours(12).ms;
13
+
14
+ return new Date(midday).toISOString().slice(0, 10) as DayIsoIdType;
14
15
  }
15
16
 
16
17
  previous(): Day {
17
- const shifted = addDays(new Date(this.getStart()), -1).getTime();
18
+ const shifted = this.getStart() - Time.Days(1).ms;
19
+
18
20
  return Day.fromTimestamp(Timestamp.parse(shifted));
19
21
  }
20
22
 
21
23
  next(): Day {
22
- const shifted = addDays(new Date(this.getStart()), 1).getTime();
24
+ const shifted = this.getStart() + Time.Days(1).ms;
25
+
23
26
  return Day.fromTimestamp(Timestamp.parse(shifted));
24
27
  }
25
28
 
26
29
  shift(count: number): Day {
27
- const shifted = addDays(new Date(this.getStart()), count).getTime();
30
+ const shifted = this.getStart() + count * Time.Days(1).ms;
31
+
28
32
  return Day.fromTimestamp(Timestamp.parse(shifted));
29
33
  }
30
34
 
31
35
  static fromTimestamp(timestamp: TimestampType): Day {
32
- const start = Timestamp.parse(startOfDay(timestamp).getTime());
33
- const end = Timestamp.parse(endOfDay(timestamp).getTime());
34
- return new Day(start, end);
36
+ const date = new Date(timestamp);
37
+ const startUtc = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
38
+ const endUtc = startUtc + Time.Days(1).ms - 1;
39
+
40
+ return new Day(Timestamp.parse(startUtc), Timestamp.parse(endUtc));
35
41
  }
36
42
 
37
43
  static fromNow(now: TimestampType): Day {
@@ -40,8 +46,9 @@ export class Day extends DateRange {
40
46
 
41
47
  static fromIsoId(isoId: DayIsoIdType): Day {
42
48
  const [year, month, day] = DayIsoId.parse(isoId).split("-").map(Number);
49
+ const startUtc = Date.UTC(year, month - 1, day);
50
+ const endUtc = startUtc + Time.Days(1).ms - 1;
43
51
 
44
- const reference = new Date(Date.UTC(year, month - 1, day));
45
- return Day.fromTimestamp(Timestamp.parse(reference.getTime()));
52
+ return new Day(Timestamp.parse(startUtc), Timestamp.parse(endUtc));
46
53
  }
47
54
  }
@@ -1,12 +1,19 @@
1
1
  import { z } from "zod/v4";
2
2
 
3
+ export const AbsDirTypeError = "abs_dir.not.string" as const;
4
+ export const AbsDirMustStartWithSlashError = "abs_dir_must_start_with_slash" as const;
5
+ export const AbsDirBackslashForbiddenError = "abs_dir_backslash_forbidden" as const;
6
+ export const AbsDirControlCharsForbiddenError = "abs_dir_control_chars_forbidden" as const;
7
+ export const AbsDirBadSegmentsError = "abs_dir_bad_segments" as const;
8
+
3
9
  export const DirectoryPathAbsoluteSchema = z
4
- .string()
10
+ .string(AbsDirTypeError)
5
11
  .trim()
6
- .refine((value) => value.startsWith("/"), "abs_dir_must_start_with_slash")
7
- .refine((value) => !value.includes("\\"), "abs_dir_backslash_forbidden")
12
+ .refine((value) => value.startsWith("/"), AbsDirMustStartWithSlashError)
13
+ .refine((value) => !value.includes("\\"), AbsDirBackslashForbiddenError)
8
14
  // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
9
- .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), "abs_dir_control_chars_forbidden")
15
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), AbsDirControlCharsForbiddenError)
16
+ // collapse duplicate slashes, then drop trailing slash unless it's the root "/"
10
17
  .transform((value) => value.replace(/\/{2,}/g, "/"))
11
18
  .transform((value) => (value !== "/" && value.endsWith("/") ? value.slice(0, -1) : value))
12
19
  .refine((value) => {
@@ -16,7 +23,7 @@ export const DirectoryPathAbsoluteSchema = z
16
23
  (segment) =>
17
24
  segment.length > 0 && /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== "..",
18
25
  );
19
- }, "abs_dir_bad_segments")
26
+ }, AbsDirBadSegmentsError)
20
27
  .brand("directory_path_absolute");
21
28
 
22
29
  export type DirectoryPathAbsoluteType = z.infer<typeof DirectoryPathAbsoluteSchema>;
@@ -1,21 +1,28 @@
1
1
  import { z } from "zod/v4";
2
2
 
3
+ export const RelDirTypeError = "rel_dir.not.string" as const;
4
+ export const RelDirMustNotStartWithSlashError = "rel_dir_must_not_start_with_slash" as const;
5
+ export const RelDirBackslashForbiddenError = "rel_dir_backslash_forbidden" as const;
6
+ export const RelDirControlCharsForbiddenError = "rel_dir_control_chars_forbidden" as const;
7
+ export const RelDirEmptyError = "rel_dir_empty" as const;
8
+ export const RelDirBadSegmentsError = "rel_dir_bad_segments" as const;
9
+
3
10
  export const DirectoryPathRelativeSchema = z
4
- .string()
11
+ .string(RelDirTypeError)
5
12
  .trim()
6
- .refine((value) => !value.startsWith("/"), "rel_dir_must_not_start_with_slash")
7
- .refine((value) => !value.includes("\\"), "rel_dir_backslash_forbidden")
13
+ .refine((value) => !value.startsWith("/"), RelDirMustNotStartWithSlashError)
14
+ .refine((value) => !value.includes("\\"), RelDirBackslashForbiddenError)
8
15
  // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
9
- .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), "rel_dir_control_chars_forbidden")
16
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), RelDirControlCharsForbiddenError)
10
17
  .transform((value) => value.replace(/\/{2,}/g, "/"))
11
18
  .transform((value) => value.replace(/^\/+|\/+$/g, ""))
12
- .refine((value) => value.length > 0, "rel_dir_empty")
19
+ .refine((value) => value.length > 0, RelDirEmptyError)
13
20
  .refine(
14
21
  (value) =>
15
22
  value
16
23
  .split("/")
17
24
  .every((segment) => /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== ".."),
18
- "rel_dir_bad_segments",
25
+ RelDirBadSegmentsError,
19
26
  )
20
27
  .brand("directory_path_relative");
21
28
 
@@ -9,13 +9,11 @@ export class Node<T> {
9
9
 
10
10
  forward(n: number): Node<T> | null {
11
11
  let currentNode: Node<T> | null = this;
12
+ let steps = n;
12
13
 
13
- for (let i = 0; i < n; i++) {
14
- if (currentNode === null) {
15
- return currentNode;
16
- }
17
-
14
+ while (steps > 0 && currentNode) {
18
15
  currentNode = currentNode.next;
16
+ steps -= 1;
19
17
  }
20
18
 
21
19
  return currentNode;
@@ -23,14 +21,13 @@ export class Node<T> {
23
21
 
24
22
  backward(n: number): Node<T> | null {
25
23
  let currentNode: Node<T> | null = this;
24
+ let steps = n;
26
25
 
27
- for (let i = 0; i < n; i++) {
28
- if (currentNode === null) {
29
- return currentNode;
30
- }
31
-
26
+ while (steps > 0 && currentNode) {
32
27
  currentNode = currentNode.prev;
28
+ steps -= 1;
33
29
  }
30
+
34
31
  return currentNode;
35
32
  }
36
33
  }
@@ -58,39 +55,31 @@ export class DoublyLinkedList<T> {
58
55
  }
59
56
 
60
57
  append(node: Node<T>): void {
61
- if (this.head === null || this.tail === null) {
62
- this.size++;
63
-
58
+ if (this.tail === null) {
64
59
  this.head = node;
65
60
  this.tail = node;
66
61
  } else {
67
- this.size++;
68
-
69
62
  this.tail.next = node;
70
63
  node.prev = this.tail;
71
-
72
64
  this.tail = node;
73
65
  }
66
+ this.size += 1;
74
67
  }
75
68
 
76
69
  prepend(node: Node<T>): void {
77
- if (this.head === null || this.tail === null) {
78
- this.size++;
79
-
70
+ if (this.head === null) {
80
71
  this.head = node;
81
72
  this.tail = node;
82
73
  } else {
83
- this.size++;
84
-
85
74
  node.next = this.head;
86
75
  this.head.prev = node;
87
76
  this.head = node;
88
77
  }
78
+ this.size += 1;
89
79
  }
90
80
 
91
81
  clear(): void {
92
82
  this.size = 0;
93
-
94
83
  this.head = null;
95
84
  this.tail = null;
96
85
  }
@@ -108,7 +97,7 @@ export class DoublyLinkedList<T> {
108
97
  this.tail = node.prev;
109
98
  }
110
99
 
111
- this.size--;
100
+ this.size -= 1;
112
101
  node.prev = null;
113
102
  node.next = null;
114
103
  }
@@ -116,40 +105,57 @@ export class DoublyLinkedList<T> {
116
105
  insertAfter(node: Node<T>, target: Node<T>): void {
117
106
  if (target === this.tail) {
118
107
  this.append(node);
119
- } else {
120
- this.size++;
108
+ return;
109
+ }
110
+
111
+ const nextNode = target.next;
112
+ this.size += 1;
113
+
114
+ node.prev = target;
115
+ node.next = nextNode;
121
116
 
122
- node.prev = target;
123
- node.next = target.next;
124
- // biome-ignore lint: lint/style/noNonNullAssertion
125
- target.next!.prev = node;
126
- target.next = node;
117
+ if (nextNode) {
118
+ nextNode.prev = node;
127
119
  }
120
+
121
+ target.next = node;
128
122
  }
129
123
 
130
124
  insertBefore(node: Node<T>, target: Node<T>): void {
131
125
  if (target === this.head) {
132
126
  this.prepend(node);
133
- } else {
134
- this.size++;
127
+ return;
128
+ }
129
+
130
+ const prevNode = target.prev;
131
+ this.size += 1;
135
132
 
136
- node.next = target;
137
- node.prev = target.prev;
138
- // biome-ignore lint: lint/style/noNonNullAssertion
139
- target.prev!.next = node;
140
- target.prev = node;
133
+ node.next = target;
134
+ node.prev = prevNode;
135
+
136
+ if (prevNode) {
137
+ prevNode.next = node;
141
138
  }
139
+
140
+ target.prev = node;
142
141
  }
143
142
 
144
143
  find(callback: (node: Node<T>) => boolean): Node<T> | null {
145
- return Array.from(this).find(callback) ?? null;
144
+ let current = this.head;
145
+ while (current) {
146
+ if (callback(current)) return current;
147
+ current = current.next;
148
+ }
149
+ return null;
146
150
  }
147
151
 
148
152
  reverse(): void {
149
153
  [this.head, this.tail] = [this.tail, this.head];
150
154
 
151
155
  for (const node of this) {
152
- [node.prev, node.next] = [node.next, node.prev];
156
+ const originalNext = node.next;
157
+ node.next = node.prev;
158
+ node.prev = originalNext;
153
159
  }
154
160
  }
155
161
 
@@ -159,20 +165,16 @@ export class DoublyLinkedList<T> {
159
165
 
160
166
  static fromArray<T>(array: T[]): DoublyLinkedList<T> {
161
167
  const dll = new DoublyLinkedList<T>();
162
-
163
168
  for (const item of array) {
164
169
  dll.append(new Node<T>(item));
165
170
  }
166
-
167
171
  return dll;
168
172
  }
169
173
 
170
174
  *[Symbol.iterator](): IterableIterator<Node<T>> {
171
175
  let current: Node<T> | null = this.head;
172
-
173
176
  while (current) {
174
177
  yield current;
175
-
176
178
  current = current.next;
177
179
  }
178
180
  }