UNPKG

universal-common

Version:

Library that provides useful missing base class library functionality.

744 lines (683 loc) 29.5 kB
import ArgumentError from './ArgumentError.js'; import TimeSpan from './TimeSpan.js'; import DateTime from './DateTime.js'; /** * Represents a time of day, as would be read from a clock, within the range 00:00:00 to 23:59:59.9999999. * * TimeOnly provides time-only functionality without date components, making it ideal * for scenarios where only the time portion is relevant (schedules, opening hours, etc.). * * @example * // Create TimeOnly instances * const morning = new TimeOnly(9, 30); // 09:30:00 * const precise = new TimeOnly(14, 15, 30, 500); // 14:15:30.500 * const fromTicks = new TimeOnly(450000000000); // From ticks * * // Time arithmetic * const later = morning.addHours(2.5); // 12:00:00 * const duration = later.subtract(morning); // 2:30:00 TimeSpan * * // Comparisons and ranges * console.log(morning.isBetween(new TimeOnly(8, 0), new TimeOnly(17, 0))); // true * * // Formatting * console.log(morning.toString()); // "09:30:00" * console.log(precise.toString()); // "14:15:30.5" */ export default class TimeOnly { /** @private @type {bigint} Internal ticks storage */ #ticks; /** @private @type {bigint} Minimum time ticks (midnight) */ static #MIN_TIME_TICKS = 0n; /** @private @type {bigint} Maximum time ticks (23:59:59.9999999) */ static #MAX_TIME_TICKS = BigInt(TimeSpan.TICKS_PER_DAY) - 1n; /** * Creates a new TimeOnly instance. * * @constructor * @param {...number} args - Constructor arguments in various formats: * - (ticks) - 100-nanosecond intervals since midnight * - (hour, minute) - Hour and minute components * - (hour, minute, second) - Hour, minute, and second components * - (hour, minute, second, millisecond) - With milliseconds * - (hour, minute, second, millisecond, microsecond) - With microseconds * * @throws {ArgumentError} If invalid number of arguments provided * @throws {RangeError} If time components are out of valid range * * @example * // From ticks * const time1 = new TimeOnly(450000000000); // 12:30:00 * * // From hour and minute * const time2 = new TimeOnly(14, 30); // 14:30:00 * * // From hour, minute, second * const time3 = new TimeOnly(9, 15, 45); // 09:15:45 * * // With milliseconds * const time4 = new TimeOnly(16, 30, 0, 500); // 16:30:00.500 */ constructor(...args) { if (args.length === 1) { // TimeOnly(ticks) const ticks = typeof args[0] === 'bigint' ? args[0] : BigInt(Math.floor(args[0])); if (ticks > TimeOnly.#MAX_TIME_TICKS || ticks < TimeOnly.#MIN_TIME_TICKS) { throw new RangeError("TimeOnly ticks out of range."); } this.#ticks = ticks; } else if (args.length === 2) { // TimeOnly(hour, minute) this.#ticks = TimeOnly.#timeToTicks(args[0], args[1], 0, 0); } else if (args.length === 3) { // TimeOnly(hour, minute, second) this.#ticks = TimeOnly.#timeToTicks(args[0], args[1], args[2], 0); } else if (args.length === 4) { // TimeOnly(hour, minute, second, millisecond) this.#ticks = TimeOnly.#timeToTicks(args[0], args[1], args[2], args[3]); } else if (args.length === 5) { // TimeOnly(hour, minute, second, millisecond, microsecond) const hour = args[0]; const minute = args[1]; const second = args[2]; const millisecond = args[3]; const microsecond = args[4]; if (hour < 0 || hour > 23) throw new RangeError("Hour must be between 0 and 23."); if (minute < 0 || minute > 59) throw new RangeError("Minute must be between 0 and 59."); if (second < 0 || second > 59) throw new RangeError("Second must be between 0 and 59."); if (millisecond < 0 || millisecond > 999) throw new RangeError("Millisecond must be between 0 and 999."); if (microsecond < 0 || microsecond > 999) throw new RangeError("Microsecond must be between 0 and 999."); this.#ticks = BigInt(hour) * BigInt(TimeSpan.TICKS_PER_HOUR) + BigInt(minute) * BigInt(TimeSpan.TICKS_PER_MINUTE) + BigInt(second) * BigInt(TimeSpan.TICKS_PER_SECOND) + BigInt(millisecond) * BigInt(TimeSpan.TICKS_PER_MILLISECOND) + BigInt(microsecond) * BigInt(TimeSpan.TICKS_PER_MICROSECOND); } else { throw new ArgumentError("Invalid TimeOnly constructor arguments."); } } /** * Converts time components to ticks. * * @private * @param {number} hour - Hour component (0-23) * @param {number} minute - Minute component (0-59) * @param {number} second - Second component (0-59) * @param {number} millisecond - Millisecond component (0-999) * @returns {bigint} Total ticks representing the time * @throws {RangeError} If any component is out of valid range */ static #timeToTicks(hour, minute, second, millisecond) { if (hour < 0 || hour > 23) throw new RangeError("Hour must be between 0 and 23."); if (minute < 0 || minute > 59) throw new RangeError("Minute must be between 0 and 59."); if (second < 0 || second > 59) throw new RangeError("Second must be between 0 and 59."); if (millisecond < 0 || millisecond > 999) throw new RangeError("Millisecond must be between 0 and 999."); return BigInt(hour) * BigInt(TimeSpan.TICKS_PER_HOUR) + BigInt(minute) * BigInt(TimeSpan.TICKS_PER_MINUTE) + BigInt(second) * BigInt(TimeSpan.TICKS_PER_SECOND) + BigInt(millisecond) * BigInt(TimeSpan.TICKS_PER_MILLISECOND); } /** * Gets the hour component of the time represented by this instance (0-23). * * @type {number} * @readonly * @example * const time = new TimeOnly(14, 30, 45); * console.log(time.hour); // 14 */ get hour() { return Number(this.#ticks / BigInt(TimeSpan.TICKS_PER_HOUR)); } /** * Gets the minute component of the time represented by this instance (0-59). * * @type {number} * @readonly * @example * const time = new TimeOnly(14, 30, 45); * console.log(time.minute); // 30 */ get minute() { return Number((this.#ticks / BigInt(TimeSpan.TICKS_PER_MINUTE)) % BigInt(TimeSpan.MINUTES_PER_HOUR)); } /** * Gets the second component of the time represented by this instance (0-59). * * @type {number} * @readonly * @example * const time = new TimeOnly(14, 30, 45); * console.log(time.second); // 45 */ get second() { return Number((this.#ticks / BigInt(TimeSpan.TICKS_PER_SECOND)) % BigInt(TimeSpan.SECONDS_PER_MINUTE)); } /** * Gets the millisecond component of the time represented by this instance (0-999). * * @type {number} * @readonly * @example * const time = new TimeOnly(14, 30, 45, 500); * console.log(time.millisecond); // 500 */ get millisecond() { return Number((this.#ticks / BigInt(TimeSpan.TICKS_PER_MILLISECOND)) % BigInt(TimeSpan.MILLISECONDS_PER_SECOND)); } /** * Gets the microsecond component of the time represented by this instance (0-999). * * @type {number} * @readonly * @example * const time = new TimeOnly(14, 30, 45, 500, 250); * console.log(time.microsecond); // 250 */ get microsecond() { return Number((this.#ticks / BigInt(TimeSpan.TICKS_PER_MICROSECOND)) % BigInt(TimeSpan.MICROSECONDS_PER_MILLISECOND)); } /** * Gets the nanosecond component of the time represented by this instance (0-900). * Note: Resolution is limited to 100-nanosecond intervals. * * @type {number} * @readonly */ get nanosecond() { return Number(this.#ticks % BigInt(TimeSpan.TICKS_PER_MICROSECOND)) * TimeSpan.NANOSECONDS_PER_TICK; } /** * Gets the number of ticks that represent the time of this instance. * * @type {number} * @readonly */ get ticks() { return Number(this.#ticks); } /** * Adds ticks to this TimeOnly, wrapping around midnight if necessary. * * @private * @param {number|bigint} ticks - The ticks to add * @returns {TimeOnly} A new TimeOnly with the added ticks */ #addTicks(ticks) { const ticksBigInt = typeof ticks === 'bigint' ? ticks : BigInt(Math.floor(ticks)); const ticksPerDay = BigInt(TimeSpan.TICKS_PER_DAY); // Add ticks and handle wrapping let newTicks = (this.#ticks + ticksPerDay + (ticksBigInt % ticksPerDay)) % ticksPerDay; return new TimeOnly(newTicks); } /** * Adds ticks to this TimeOnly, returning wrapped days as an output parameter. * * @private * @param {number|bigint} ticks - The ticks to add * @returns {{time: TimeOnly, wrappedDays: number}} Object containing the new time and wrapped days */ #addTicksWithWrappedDays(ticks) { const ticksBigInt = typeof ticks === 'bigint' ? ticks : BigInt(Math.floor(ticks)); const ticksPerDay = BigInt(TimeSpan.TICKS_PER_DAY); // Calculate days and new ticks const totalTicks = this.#ticks + ticksBigInt; let days = totalTicks / ticksPerDay; let newTicks = totalTicks % ticksPerDay; if (newTicks < 0n) { days--; newTicks += ticksPerDay; } return { time: new TimeOnly(newTicks), wrappedDays: Number(days) }; } /** * Returns a new TimeOnly that adds the value of the specified TimeSpan to the value of this instance. * * @param {TimeSpan} value - A positive or negative time interval * @returns {TimeOnly} An object whose value is the sum of the time represented by this instance and the time interval represented by value * @throws {TypeError} If value is not a TimeSpan * * @example * const morning = new TimeOnly(9, 0, 0); * const duration = TimeSpan.fromHours(2.5); * const later = morning.add(duration); // 11:30:00 */ add(value) { if (!(value instanceof TimeSpan)) { throw new TypeError("Argument must be a TimeSpan."); } return this.#addTicks(value.ticks); } /** * Returns a new TimeOnly that adds the value of the specified TimeSpan to the value of this instance. * If the result wraps past the end of the day, this method will return the number of excess days. * * @param {TimeSpan} value - A positive or negative time interval * @returns {{time: TimeOnly, wrappedDays: number}} Object containing the new time and wrapped days * @throws {TypeError} If value is not a TimeSpan * * @example * const evening = new TimeOnly(22, 0, 0); * const duration = TimeSpan.fromHours(4); * const result = evening.addWithWrappedDays(duration); * console.log(result.time.toString()); // "02:00:00" * console.log(result.wrappedDays); // 1 */ addWithWrappedDays(value) { if (!(value instanceof TimeSpan)) { throw new TypeError("Argument must be a TimeSpan."); } return this.#addTicksWithWrappedDays(value.ticks); } /** * Returns a new TimeOnly that adds the specified number of hours to the value of this instance. * * @param {number} value - A number of whole and fractional hours. The value parameter can be negative or positive * @returns {TimeOnly} An object whose value is the sum of the time represented by this instance and the number of hours represented by value * * @example * const time = new TimeOnly(10, 30, 0); * const later = time.addHours(2.5); // 13:00:00 */ addHours(value) { return this.#addTicks(value * TimeSpan.TICKS_PER_HOUR); } /** * Returns a new TimeOnly that adds the specified number of hours to the value of this instance. * If the result wraps past the end of the day, this method will return the number of excess days. * * @param {number} value - A number of whole and fractional hours. The value parameter can be negative or positive * @returns {{time: TimeOnly, wrappedDays: number}} Object containing the new time and wrapped days * * @example * const time = new TimeOnly(22, 0, 0); * const result = time.addHoursWithWrappedDays(4); * console.log(result.time.toString()); // "02:00:00" * console.log(result.wrappedDays); // 1 */ addHoursWithWrappedDays(value) { return this.#addTicksWithWrappedDays(value * TimeSpan.TICKS_PER_HOUR); } /** * Returns a new TimeOnly that adds the specified number of minutes to the value of this instance. * * @param {number} value - A number of whole and fractional minutes. The value parameter can be negative or positive * @returns {TimeOnly} An object whose value is the sum of the time represented by this instance and the number of minutes represented by value * * @example * const time = new TimeOnly(10, 30, 0); * const later = time.addMinutes(45); // 11:15:00 */ addMinutes(value) { return this.#addTicks(value * TimeSpan.TICKS_PER_MINUTE); } /** * Returns a new TimeOnly that adds the specified number of minutes to the value of this instance. * If the result wraps past the end of the day, this method will return the number of excess days. * * @param {number} value - A number of whole and fractional minutes. The value parameter can be negative or positive * @returns {{time: TimeOnly, wrappedDays: number}} Object containing the new time and wrapped days * * @example * const time = new TimeOnly(23, 30, 0); * const result = time.addMinutesWithWrappedDays(45); * console.log(result.time.toString()); // "00:15:00" * console.log(result.wrappedDays); // 1 */ addMinutesWithWrappedDays(value) { return this.#addTicksWithWrappedDays(value * TimeSpan.TICKS_PER_MINUTE); } /** * Determines if a time falls within the range provided. * Supports both "normal" ranges such as 10:00-12:00, and ranges that span midnight such as 23:00-01:00. * * @param {TimeOnly} start - The starting time of day, inclusive * @param {TimeOnly} end - The ending time of day, exclusive * @returns {boolean} True, if the time falls within the range, false otherwise * @throws {TypeError} If start or end is not a TimeOnly instance * * @example * const workStart = new TimeOnly(9, 0, 0); * const workEnd = new TimeOnly(17, 0, 0); * const currentTime = new TimeOnly(14, 30, 0); * * console.log(currentTime.isBetween(workStart, workEnd)); // true * * // Midnight-spanning range * const nightStart = new TimeOnly(22, 0, 0); * const nightEnd = new TimeOnly(6, 0, 0); * const lateNight = new TimeOnly(1, 0, 0); * * console.log(lateNight.isBetween(nightStart, nightEnd)); // true */ isBetween(start, end) { if (!(start instanceof TimeOnly) || !(end instanceof TimeOnly)) { throw new TypeError("Arguments must be TimeOnly instances."); } const time = this.#ticks; const startTicks = start.#ticks; const endTicks = end.#ticks; if (startTicks <= endTicks) { // Normal range (doesn't cross midnight) // Start is inclusive, end is exclusive: [start, end) return time >= startTicks && time < endTicks; } else { // Range crosses midnight (e.g., 22:00 to 06:00) // Time is either >= start OR < end: [start, 24:00) OR [00:00, end) return time >= startTicks || time < endTicks; } } /** * Determines whether two specified instances of TimeOnly are equal. * * @param {TimeOnly} other - The other TimeOnly to compare with * @returns {boolean} true if left and right represent the same time; otherwise, false * * @example * const time1 = new TimeOnly(14, 30, 0); * const time2 = new TimeOnly(14, 30, 0); * const time3 = new TimeOnly(14, 30, 1); * * console.log(time1.equals(time2)); // true * console.log(time1.equals(time3)); // false */ equals(other) { return other instanceof TimeOnly && this.#ticks === other.#ticks; } /** * Compares this instance to a specified TimeOnly value and indicates whether this instance is earlier than, the same as, or later than the specified TimeOnly value. * * @param {TimeOnly} other - The object to compare to the current instance * @returns {number} * - Less than zero if this instance is earlier than value * - Zero if this instance is the same as value * - Greater than zero if this instance is later than value * @throws {TypeError} If other is not a TimeOnly instance * * @example * const time1 = new TimeOnly(9, 0, 0); * const time2 = new TimeOnly(17, 0, 0); * * console.log(time1.compareTo(time2)); // -1 (time1 is earlier) * console.log(time2.compareTo(time1)); // 1 (time2 is later) * console.log(time1.compareTo(time1)); // 0 (same time) */ compareTo(other) { if (!(other instanceof TimeOnly)) { throw new TypeError("Argument must be a TimeOnly instance."); } if (this.#ticks < other.#ticks) return -1; if (this.#ticks > other.#ticks) return 1; return 0; } /** * Gives the elapsed time between two points on a circular clock, which will always be a positive value. * * @param {TimeOnly} other - The other TimeOnly instance * @returns {TimeSpan} The elapsed time between this and other * @throws {TypeError} If other is not a TimeOnly instance * * @example * const morning = new TimeOnly(9, 0, 0); * const evening = new TimeOnly(17, 0, 0); * * const duration = evening.subtract(morning); * console.log(duration.toString()); // "08:00:00" * * // Circular clock behavior - always positive * const reverseDuration = morning.subtract(evening); * console.log(reverseDuration.toString()); // "16:00:00" (going forward around the clock) */ subtract(other) { if (!(other instanceof TimeOnly)) { throw new TypeError("Argument must be a TimeOnly instance."); } let diff = Number(this.#ticks - other.#ticks); // If the result is negative, add 24 hours to make it positive (circular clock) if (diff < 0) { diff += TimeSpan.TICKS_PER_DAY; } return new TimeSpan(diff); } /** * Converts the value of the current TimeOnly object to its equivalent string representation. * * @returns {string} A string representation of the TimeOnly in HH:mm:ss format, with fractional seconds if present * * @example * const time1 = new TimeOnly(9, 30, 0); * console.log(time1.toString()); // "09:30:00" * * const time2 = new TimeOnly(14, 15, 30, 500); * console.log(time2.toString()); // "14:15:30.5" * * const time3 = new TimeOnly(23, 59, 59, 999, 999); * console.log(time3.toString()); // "23:59:59.999999" */ toString() { const hour = this.hour; const minute = this.minute; const second = this.second; const fraction = Number(this.#ticks % BigInt(TimeSpan.TICKS_PER_SECOND)); let result = `${hour.toString().padStart(2, '0')}:`; result += `${minute.toString().padStart(2, '0')}:`; result += second.toString().padStart(2, '0'); if (fraction !== 0) { // Convert fraction to string and remove trailing zeros result += `.${fraction.toString().padStart(7, '0').replace(/0+$/, '')}`; } return result; } /** * Converts the value of the current TimeOnly object to its equivalent long time string representation. * * @returns {string} A string that contains the long time string representation of the current TimeOnly object */ toLongTimeString() { return this.toString(); } /** * Converts the value of the current TimeOnly object to its equivalent short time string representation. * * @returns {string} A string that contains the short time string representation of the current TimeOnly object */ toShortTimeString() { const hour = this.hour; const minute = this.minute; return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; } /** * Constructs a TimeOnly object from a TimeSpan representing the time elapsed since midnight. * * @param {TimeSpan} timeSpan - The time interval measured since midnight. This value has to be positive and not exceeding 24 hours * @returns {TimeOnly} A TimeOnly object representing the time elapsed since midnight using the timeSpan value * @throws {TypeError} If timeSpan is not a TimeSpan instance * @throws {RangeError} If timeSpan is negative or exceeds 24 hours * * @example * const span = TimeSpan.fromHours(14.5); // 14 hours 30 minutes * const time = TimeOnly.fromTimeSpan(span); * console.log(time.toString()); // "14:30:00" */ static fromTimeSpan(timeSpan) { if (!(timeSpan instanceof TimeSpan)) { throw new TypeError("Argument must be a TimeSpan."); } const ticks = timeSpan.ticks; if (ticks < 0 || ticks >= TimeSpan.TICKS_PER_DAY) { throw new RangeError("TimeSpan must be non-negative and less than 24 hours."); } return new TimeOnly(ticks); } /** * Constructs a TimeOnly object from a DateTime representing the time of the day in this DateTime object. * * @param {DateTime} dateTime - The DateTime object to extract the time of the day from * @returns {TimeOnly} A TimeOnly object representing time of the day specified in the DateTime object * @throws {TypeError} If dateTime is not a DateTime instance * * @example * const dateTime = new DateTime(2024, 12, 25, 14, 30, 45); * const timeOnly = TimeOnly.fromDateTime(dateTime); * console.log(timeOnly.toString()); // "14:30:45" */ static fromDateTime(dateTime) { if (!(dateTime instanceof DateTime)) { throw new TypeError("Argument must be a DateTime."); } const timeTicks = BigInt(dateTime.ticks) % BigInt(TimeSpan.TICKS_PER_DAY); return new TimeOnly(timeTicks); } /** * Convert the current TimeOnly instance to a TimeSpan object. * * @returns {TimeSpan} A TimeSpan object spanning to the time specified in the current TimeOnly object * * @example * const time = new TimeOnly(14, 30, 45); * const span = time.toTimeSpan(); * console.log(span.toString()); // "14:30:45" */ toTimeSpan() { return new TimeSpan(Number(this.#ticks)); } /** * Gets the smallest possible value of TimeOnly (00:00:00.0000000). * * @type {TimeOnly} * @readonly * @static * * @example * const minTime = TimeOnly.minValue; * console.log(minTime.toString()); // "00:00:00" */ static get minValue() { return new TimeOnly(TimeOnly.#MIN_TIME_TICKS); } /** * Gets the largest possible value of TimeOnly (23:59:59.9999999). * * @type {TimeOnly} * @readonly * @static * * @example * const maxTime = TimeOnly.maxValue; * console.log(maxTime.toString()); // "23:59:59.9999999" */ static get maxValue() { return new TimeOnly(TimeOnly.#MAX_TIME_TICKS); } /** * Compares two TimeOnly values and returns an indication of their relative values. * * @param {TimeOnly} t1 - The first TimeOnly * @param {TimeOnly} t2 - The second TimeOnly * @returns {number} -1 if t1 is earlier than t2, 0 if equal, 1 if t1 is later than t2 * * @example * const morning = new TimeOnly(9, 0, 0); * const evening = new TimeOnly(17, 0, 0); * * console.log(TimeOnly.compare(morning, evening)); // -1 * console.log(TimeOnly.compare(evening, morning)); // 1 * console.log(TimeOnly.compare(morning, morning)); // 0 */ static compare(t1, t2) { if (!(t1 instanceof TimeOnly) || !(t2 instanceof TimeOnly)) { throw new TypeError("Both arguments must be TimeOnly instances."); } return t1.compareTo(t2); } /** * Returns the primitive value of this TimeOnly (ticks). * * This method is called automatically when the TimeOnly is used in contexts * that require a primitive value, such as arithmetic operations or comparisons. * * @returns {number} The ticks value representing this time * * @example * const time = new TimeOnly(12, 0, 0); * console.log(+time); // Outputs the ticks value * console.log(time.valueOf()); // Same as above */ valueOf() { return Number(this.#ticks); } /** * Parses a string representation of a time and returns a TimeOnly instance. * * The string can be in various formats: * - "HH:mm" (e.g., "14:30") * - "HH:mm:ss" (e.g., "14:30:45") * - "HH:mm:ss.fff" (e.g., "14:30:45.123") * - "HH:mm:ss.fffffff" (e.g., "14:30:45.1234567") * * @param {string} s - The string to parse * @returns {TimeOnly} A TimeOnly instance representing the parsed time * @throws {TypeError} If the input is not a string * @throws {ArgumentError} If the string format is invalid * @throws {RangeError} If the time components are out of valid range * * @example * const time1 = TimeOnly.parse("14:30"); // 14:30:00 * const time2 = TimeOnly.parse("09:15:30"); // 09:15:30 * const time3 = TimeOnly.parse("16:45:30.500"); // 16:45:30.5 */ static parse(s) { if (typeof s !== "string") { throw new TypeError("Input must be a string."); } // Try HH:mm:ss.fffffff format let match = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,7}))?)?$/); if (match) { const hour = parseInt(match[1], 10); const minute = parseInt(match[2], 10); const second = match[3] ? parseInt(match[3], 10) : 0; const fraction = match[4] ? match[4].padEnd(7, '0') : '0000000'; const ticks = parseInt(fraction, 10); if (hour > 23 || minute > 59 || second > 59) { throw new RangeError("Time components out of range."); } const totalTicks = BigInt(hour) * BigInt(TimeSpan.TICKS_PER_HOUR) + BigInt(minute) * BigInt(TimeSpan.TICKS_PER_MINUTE) + BigInt(second) * BigInt(TimeSpan.TICKS_PER_SECOND) + BigInt(ticks); return new TimeOnly(totalTicks); } throw new ArgumentError("Invalid TimeOnly format."); } /** * Attempts to parse a string representation of a time and returns the result. * * Unlike parse(), this method does not throw exceptions on invalid input. * * @param {string} s - The string to parse * @returns {{success: boolean, value: TimeOnly|null}} * An object containing: * - success: true if parsing succeeded, false otherwise * - value: The parsed TimeOnly instance if successful, null otherwise * * @example * const result1 = TimeOnly.tryParse("14:30:45"); * if (result1.success) { * console.log(result1.value.toString()); // "14:30:45" * } * * const result2 = TimeOnly.tryParse("invalid-time"); * console.log(result2.success); // false * console.log(result2.value); // null */ static tryParse(s) { try { return { success: true, value: TimeOnly.parse(s) }; } catch { return { success: false, value: null }; } } }