Skip to main content
Home

latest

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
It is unknown whether this package works with Cloudflare Workers
This package works with Node.js
This package works with Deno
This package works with Bun
This package works with Browsers
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; }; }