Utility for doing math on strings in the format "hh:mm", such as adding, subtracting, intersection testing and modulo
This package works with Node.js, Deno, Bun, BrowsersIt is unknown whether this package works with Cloudflare Workers




JSR Score
94%
Published
a year ago (0.1.2)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682/** * Symbol added as a property on all strings. Use this as the most convenient way * to convert a plain string to an HHMM object. * @example * ``` * import { HM } from "@revosw/hhmm" * const from = "10:00" * const duration = "02:00" * const to = "12:00" * if (from[HM].add(duration) > to[HM]) { * throw new Error("From + duration cannot be greater than to") * } * ``` */ export const HM: unique symbol = Symbol("HHMM"); /** * Makes it easy to add, subtract, divide, test intersection and more on hh:mm-based time. */ export class HHMM { // hours can be found through minutes, // no need for extra state #minute = 0; constructor(hhmm: HHMM | string | number) { if (hhmm instanceof HHMM) { this.#minute = hhmm.#minute; } else if (typeof hhmm === "string") { this.#minute = HHMM.parseToMinutes(hhmm); } else if (typeof hhmm === "number") { this.#minute = hhmm; } else { throw new Error( `Got ${hhmm}, but HHMM must be constructed from another HHMM, string or number`, ); } } /** * Add two times together * @example * ``` * const time1 = "00:15" * const time2 = "00:45" * const time3 = time1[HM].add(time2) * // ^? HHMM { minute = 60 } * ``` */ public add(other: HHMM | string | number): HHMM { const parsedOther = new HHMM(other); return new HHMM(this.#minute + parsedOther.#minute); } /** * Add two times together, then convert to string. * Note that times over "23:59" will be modulo'd * back to "00:00" when calling toString * @example * ``` * const time1 = "00:15" * const time2 = "00:45" * const time3 = time1[HM].$add(time2) * // ^? "01:00" * const time4 = "23:00"[HM].$add("02:00") * // ^? "01:00" * ``` */ public $add(other: HHMM | string | number): string { return this.add(other).toString(); } /** * Subtract one time from another. * @example * ``` * const time1 = "01:00" * const time2 = "00:15" * const time3 = time1[HM].subtract(time2) * // ^? HHMM { minute = 45 } * ``` */ public subtract(other: HHMM | string | number): HHMM { const parsedOther = new HHMM(other); if (this.#minute < parsedOther.#minute) { // return new HHMM(this.#minute + 1440 - parsedOther.#minute); throw new Error("Negative duration not allowed"); } return new HHMM(this.#minute - parsedOther.#minute); } /** * Subtract one time from another, then convert to string. * Note that times over "23:59" will be modulo'd * back to "00:00" when calling toString * @example * ``` * const time1 = "00:15" * const time2 = "00:45" * const time3 = time1[HM].$add(time2) * // ^? "01:00" * const time4 = "26:00"[HM].$subtract("01:00") * // ^? "02:00" * ``` */ public $subtract(other: HHMM | string | number): string { return this.subtract(other).toString(); } /** * Divide a time with another time, and get the quotient * * @example * ``` * const time = "03:00" * const divisor = "00:30" * const time3 = time[HM].divide(divisor) * // ^? 6 * ``` */ public divide(divisor: HHMM | string | number): number { const parsedDivisor = new HHMM(divisor); const quotient = this.#minute / parsedDivisor.#minute; return Math.floor(quotient); }; /** * Checks if `other` is divisible by `this` without remainder * @example * ``` * const duration = "04:00" * const interval = "00:30" * const noRemainder = duration[HM].isDivisible(interval) * // ^? true * const hasRemainder = "01:00"[HM].isDivisible("00:19") * // ^? false * ``` */ public isDivisible(other: HHMM | string | number): boolean { const parsedOther = new HHMM(other); return this.#minute % parsedOther.#minute === 0; } /** * eq = equal * * Given another time, check if `this` and the input time represent the same time. * * If you're using the {@link HM} symbol property, you most likely * don't want to use this function. You will be dealing with * plain strings, so just check whether one string equals * the other string. * * If you're working with HHMM objects, you must use this function * to compare equality of two HHMM objects. It's not possible to override * `==` and `===` in javascript. If you compare with `===`, you end * up comparing object references, instead of the value they represent. * * ``` * const time = new HHMM("20:00") * time.eq(time) * time.eq("24:00") * time.eq(600) // comparing minutes. 600 minutes = 10:00 * ``` */ public eq(other: HHMM | string | number): boolean { return this.#minute === new HHMM(other).#minute; } /** * lt = less than * * Checks whether `this` represents a time earlier than the input time * * It's recommended to use the {@link HM} symbol property, but there are * multiple ways to compare less than. * ``` * const time1 = "08:00" * const time2 = "09:00" * * // ✅ Most ergonomic and readable * if (time1[HM] < time2[HM]) * * // 🟧 Not a bad solution either, but at-a-glance readability suffers * if (time1[HM].lt(time2)) * * // 🟧 Works, but there's 6 more characters to read and write for each `new HHMM()` compared * // to using the HM symbol property. Multiply that with the number of times * // you are doing any comparison, add/subtract etc. * if (new HHMM(time1) < new HHMM(time2)) * * // 🟥 Worst of both worlds * if (new HHMM(time1).lt(time2)) * ``` * * @example * ``` * const time = new HHMM("20:00") * time.lt("24:00") * time.lt(600) // comparing minutes. 600 minutes = 10:00 * ``` */ public lt(other: string | number): boolean { return this < new HHMM(other); } /** * lte = less than or equal * * Checks whether `this` represents a time earlier than or equal the input time * * It's recommended to use the {@link HM} symbol property, but there are * multiple ways to compare less than. * ``` * const time1 = "08:00" * const time2 = "09:00" * * // ✅ Most ergonomic and readable * if (time1[HM] < time2[HM]) * * // 🟧 Not a bad solution either, but at-a-glance readability suffers * if (time1[HM].lt(time2)) * * // 🟧 Works, but there's 6 more characters to read and write for each `new HHMM()` compared * // to using the HM symbol property. Multiply that with the number of times * // you are doing any comparison, add/subtract etc. * if (new HHMM(time1) < new HHMM(time2)) * * // 🟥 Worst of both worlds * if (new HHMM(time1).lt(time2)) * ``` * * @example * ``` * const time = new HHMM("20:00") * time.lt("24:00") * time.lt(600) // comparing minutes. 600 minutes = 10:00 * ``` */ public lte(other: string | number): boolean { return this <= new HHMM(other); } /** * gt = greater than * * Checks whether `this` represents a time later than the input time * * It's recommended to use the {@link HM} symbol property, but there are * multiple ways to do the comparison. * ``` * const time1 = "08:00" * const time2 = "09:00" * * // ✅ Most ergonomic and readable * if (time1[HM] > time2[HM]) * * // 🟧 Not a bad solution either, but at-a-glance readability suffers * if (time1[HM].gt(time2)) * * // 🟧 Works, but there's 6 more characters to read and write for each `new HHMM()` compared * // to using the HM symbol property. Multiply that with the number of times * // you are doing any comparison, add/subtract etc. * if (new HHMM(time1) > new HHMM(time2)) * * // 🟥 Worst of both worlds * if (new HHMM(time1).gt(time2)) * ``` * * @example * ``` * const time = new HHMM("20:00") * time.lt("24:00") * time.lt(600) // comparing minutes. 600 minutes = 10:00 * ``` */ public gt(other: string | number): boolean { return this > new HHMM(other); } /** * gte = greater than or equal * * Checks whether `this` represents a time later than or equal the input time * * It's recommended to use the {@link HM} symbol property, but there are * multiple ways to do the comparison. * ``` * const time1 = "08:00" * const time2 = "09:00" * * // ✅ Most ergonomic and readable * if (time1[HM] >= time2[HM]) * * // 🟧 Not a bad solution either, but at-a-glance readability suffers * if (time1[HM].gte(time2)) * * // 🟧 Works, but there's 6 more characters to read and write for each `new HHMM()` compared * // to using the HM symbol property. Multiply that with the number of times * // you are doing any comparison, add/subtract etc. * if (new HHMM(time1) >= new HHMM(time2)) * * // 🟥 Worst of both worlds * if (new HHMM(time1).gte(time2)) * ``` * * @example * ``` * const time = new HHMM("20:00") * time.lt("24:00") * time.lt(600) // comparing minutes. 600 minutes = 10:00 * ``` */ public gte(other: string | number): boolean { return this >= new HHMM(other); } /** * Generate all intervals between a start time and end time. * If end is less than start, generate all intervals between * start and end + 24h * * @example * ``` * for (const interval of "08:00"[HM].intervals("12:00", "00:15")) { * // 1. "08:00" * // 2. "08:15" * // 3. "08:30" * // ... * // n. "12:00" * } * ``` * @example * ``` * const from = "08:00" * const to = "12:00" * const intervals = [...from[HM].intervals(to, "00:15")] * ``` * @example * ``` * const from = "20:00" * const to = "02:00" * for (const interval of from[HM].intervals(to, "00:15")) { * // 1. "20:00" * // 2. "20:15" * // 3. "20:30" * // ... * // n. "26:00" * } * ``` */ public *intervals( to: HHMM | string | number, interval: HHMM | string | number, ): Generator<HHMM> { let parsedTo = new HHMM(to); if (this > parsedTo) { parsedTo = parsedTo.add("24:00"); } const parsedInterval = new HHMM(interval); let currentTime = new HHMM(this); while (currentTime < parsedTo) { yield currentTime; currentTime = currentTime.add(parsedInterval); } } /** * Checks if `this` is a time between two other times [start, end). * By default, the check is inclusive on the start, and exclusive on the end. * In other words: * @example * ``` * "08:00"[HM].intersects("08:00", "16:00") * // ^? true, start is inclusive * "12:00"[HM].intersects("08:00", "16:00") * // ^? true, is between * "16:00"[HM].intersects("08:00", "16:00") * // ^? false, end is exclusive * ``` */ public intersects( from: HHMM | string | number, to: HHMM | string | number, options: { from: "inclusive" | "exclusive"; to: "inclusive" | "exclusive"; } = { from: "inclusive", to: "exclusive" }, ): boolean { const parsedFrom = new HHMM(from); const parsedTo = new HHMM(to); const isInclusiveOrExclusiveFrom = options.from === "inclusive" ? parsedFrom <= this : parsedFrom < this; const isInclusiveOrExclusiveTo = options.to === "inclusive" ? this <= parsedTo : this < parsedTo; return isInclusiveOrExclusiveFrom && isInclusiveOrExclusiveTo; } /** * Checks if [fromA, toA) intersects [fromB, toB) * @example * ``` * HHMM.intersects("04:00", "10:00", "06:00", "07:00") * // ^? true * // A: ■■■■■■■■■■■■■■■■■■■■ * // B: ■■■■ * * HHMM.intersects("01:00", "02:00", "03:00", "04:00") * // ^? false * // A: ■■■■ * // B: ■■■■ * * HHMM.intersects("04:00", "06:00", "06:00", "07:00") * // ^? false, end is exclusive * // A: ■■■■■■■■ * // B: ■■■■ * ``` */ public static intersects( fromA: HHMM | string | number, toA: HHMM | string | number, fromB: HHMM | string | number, toB: HHMM | string | number, ): boolean { const _fromA = new HHMM(fromA); const _toA = new HHMM(toA); const _fromB = new HHMM(fromB); const _toB = new HHMM(toB); // Case 1: fromB between fromA and toA // 👇 👇 // A: ■■■■■ // B: ■■■■■ // 👆 // fromB < toA is exclusive, and not inclusive. This is to prevent bugs // where fromB is perfectly aligned with toA, (17:00 and 17:00 for example) // which is not considered intersecting. This applies to all cases. if (_fromA <= _fromB && _fromB < _toA) return true; // Case 2: toA between fromB and toB // 👇 // A: ■■■■■ // B: ■■■■■ // 👆 👆 if (_fromB < _toA && _toA <= _toB) return true; // Case 3: fromA between fromB and toB // 👇 // A: ■■■■■ // B: ■■■■■ // 👆 👆 if (_fromB <= _fromA && _fromA < _toB) return true; // Case 4: toB between fromA and toA // 👇 👇 // A: ■■■■■ // B: ■■■■■ // 👆 if (_fromA < _toB && _toB <= _toA) return true; return false; } /** * Converts the HHMM instance to a string. By default the time * is normalized to be between "00:00" and "23:59", since the library * optimizes for displaying to end users. * @example * ``` * "12:00"[HM].toString() * // ^? "12:00" * "12:00"[HM].toString(false) * // ^? "12:00" * "26:00"[HM].toString() * // ^? "02:00" * "26:00"[HM].toString(false) * // ^? "26:00" * ``` */ public toString(normalize = true): string { let hours = (this.#minute - (this.#minute % 60)) / 60; const minutes = this.#minute % 60; if (normalize) { hours = hours % 24; } return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; } /** * This method is used by JSON.stringify(), it's not intended for you * to use. You can use `.toString(false)` instead. Try to avoid * as much as possble passing HHMM objects around your program, * and keep all of them as plain strings. It will make your life * a lot less miserable. */ public toJSON(): string { return this.toString(false); } /** * Round a time to nearest interval using flooring or rounding. * @example * ``` * "12:10"[HM].round("00:15") * // ^? "12:00", floor by default * "12:10"[HM].round("00:15", "round") * // ^? "12:15", since "12:15" is closer than "12:00" * ``` */ public round( toNearestInterval: HHMM | string | number, method: "round" | "floor" = "floor", ): HHMM { const parsedToNearest = new HHMM(toNearestInterval); if (method === "floor") { return new HHMM( Math.trunc(this.#minute / +parsedToNearest) * +parsedToNearest, ); } return new HHMM( Math.round(this.#minute / +parsedToNearest) * +parsedToNearest, ); } /** * Normalize the time to be within 00:00 - 23:59. * * ``` * "23:00"[HM].normalize() * // ^? HHMM { minutes = 1380 } * "24:00"[HM].normalize() * // ^? HHMM { minutes = 0 } * "25:00"[HM].normalize() * // ^? HHMM { minutes = 60 } * ``` */ public normalize(): HHMM { if (this.#minute >= 1440) { return this.subtract("24:00"); } return this; } /** * Normalize the time to be within 00:00 - 23:59, then convert to string. * The result is exactly the same as calling `.toString()`, * but the normalize function name might be clearer than `.toString()`, * but that's just a guess. * * ``` * "23:00"[HM].$normalize() * // ^? HHMM { minutes = 1380 } * "24:00"[HM].normalize() * // ^? HHMM { minutes = 0 } * "25:00"[HM].normalize() * // ^? HHMM { minutes = 60 } * ``` */ public $normalize(): string { return this.toString() } /** * Possibly add a 24 hour offset if the input time is greater * than itself. * * @example `this < other` * ``` * const start = new HHMM("20:00") * const end = new HHMM("03:00") * const denormalized = end.denormalize(start) * // ^? HHMM { minutes = 1620 }, added 24h * ``` * * @example `this >= other` * ``` * const start = new HHMM("10:00") * const end = new HHMM("12:00") * const normtime = end.denormalize(start) * // ^? HHMM { minutes = 600 }, nothing special happens * ``` */ public denormalize(other: HHMM | string | number): HHMM { const parsedOther = new HHMM(other); if (this.#minute < parsedOther.#minute) { return this.add("24:00"); } return this; } /** * Possibly add a 24 hour offset if the input time is greater * than itself, then convert to string. * * @example `this < other` * ``` * const start = new HHMM("20:00") * const end = new HHMM("03:00") * const denormalized = end.$denormalize(start) * // ^? "27:00", added 24h * ``` * * @example `this >= other` * ``` * const start = new HHMM("10:00") * const end = new HHMM("12:00") * const denormalized = end.$denormalize(start) * // ^? "10:00", nothing special happens * ``` */ public $denormalize(other: HHMM | string | number): string { const parsedOther = new HHMM(other); if (this.#minute < parsedOther.#minute) { return this.add("24:00").toString(false); } return this.toString(false); } [Symbol.toPrimitive](hint: "number" | "string" | "default"): string | number { switch (hint) { case "string": return this.toString(); case "number": return this.#minute; case "default": return this.toString(); } } /** * The comparison function used in sorting * @example * ``` * const times = ["08:00"[HM], "04:30"[HM], "15:20"[HM]] * times.sort(HHMM.sort) * ``` */ public static sort(a: HHMM, b: HHMM): number { // Mnemonic pro tip: `a` is for ascending. // a - b: ascending // b - a: descending return a.#minute - b.#minute } /** * Create an HHMM object based on the current time with tz offset. * if UTC time is 10:00 and the caller lives in UTC+2, then .now() * returns 12:00 * * @example * ``` * const nextTurn = HHMM.now().round("00:05") * const timeUntilNextTurn = nextTurn.subtract(HHMM.now()) * ``` */ public static now(): HHMM { const today = new Date(); const currentMinute = today.getHours() * 60 + today.getMinutes(); return new HHMM(currentMinute); } /** * Converts a string formatted hh:mm to the amount of * minutes it represents. */ private static parseToMinutes = (input: string): number => { const parts = input.split(":"); const hh = parseInt(parts[0]); const mm = parseInt(parts[1]); return mm + 60 * hh; }; }