| 1 | import * as DateTime from "effect/DateTime"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | |
| 5 | const isZonedIsoDateTime = Schema.is( |
| 6 | Schema.String.check( |
| 7 | Schema.isPattern( |
| 8 | /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, |
| 9 | ), |
| 10 | Schema.isTrimmed(), |
| 11 | ), |
| 12 | ); |
| 13 | |
| 14 | function parseTimestamp(value: string): number { |
| 15 | if (!isZonedIsoDateTime(value)) return Number.NaN; |
| 16 | |
| 17 | // Engines can normalize invalid calendar dates instead of rejecting them. |
| 18 | const datePart = value.slice(0, value.indexOf("T")); |
| 19 | const date = DateTime.make(`${datePart}T00:00:00.000Z`); |
| 20 | if (Option.isNone(date)) return Number.NaN; |
| 21 | const parts = DateTime.toPartsUtc(date.value); |
| 22 | if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { |
| 23 | return Number.NaN; |
| 24 | } |
| 25 | return Date.parse(value); |
| 26 | } |
| 27 | |
| 28 | /** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ |
| 29 | export function compareDateTimeStrings(left: string, right: string): number { |
| 30 | const leftTimestamp = parseTimestamp(left); |
| 31 | const rightTimestamp = parseTimestamp(right); |
| 32 | const leftIsValid = !Number.isNaN(leftTimestamp); |
| 33 | const rightIsValid = !Number.isNaN(rightTimestamp); |
| 34 | |
| 35 | if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; |
| 36 | if (leftIsValid) return leftTimestamp - rightTimestamp; |
| 37 | return left < right ? -1 : left > right ? 1 : 0; |
| 38 | } |