Created
November 15, 2025 16:49
-
-
Save alexsasharegan/d08e4c0454c36c11be49b7de4a486c0e to your computer and use it in GitHub Desktop.
TS toolkit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { expectNever } from "./errors"; | |
| export type BinaryByteType = | |
| | "B" | |
| | "KiB" | |
| | "MiB" | |
| | "GiB" | |
| | "TiB" | |
| | "PiB" | |
| | "EiB" | |
| | "ZiB" | |
| | "YiB"; | |
| export type DecimalByteType = | |
| | "B" | |
| | "KB" | |
| | "MB" | |
| | "GB" | |
| | "TB" | |
| | "PB" | |
| | "EB" | |
| | "ZB" | |
| | "YB"; | |
| export type ByteType = BinaryByteType | DecimalByteType; | |
| export enum ByteUnit { | |
| B = 1, | |
| KiB = 1024 ** 1, | |
| MiB = 1024 ** 2, | |
| GiB = 1024 ** 3, | |
| TiB = 1024 ** 4, | |
| PiB = 1024 ** 5, | |
| EiB = 1024 ** 6, | |
| ZiB = 1024 ** 7, | |
| YiB = 1024 ** 8, | |
| KB = 1000 ** 1, | |
| MB = 1000 ** 2, | |
| GB = 1000 ** 3, | |
| TB = 1000 ** 4, | |
| PB = 1000 ** 5, | |
| EB = 1000 ** 6, | |
| ZB = 1000 ** 7, | |
| YB = 1000 ** 8, | |
| } | |
| interface BaseFormatBytesOptions { | |
| /** | |
| * When there is no remainder, pad the decimal places with zeros. | |
| */ | |
| forcePrecision?: boolean; | |
| /** | |
| * The decimal places out to which the number should be rendered. | |
| */ | |
| precision?: number; | |
| } | |
| export interface FormatBytesOptions extends BaseFormatBytesOptions { | |
| /** | |
| * Interpret the source in base 2 and render the byte units in base 2. | |
| */ | |
| useBinary?: boolean; | |
| } | |
| export interface FormatBytesSemanticallyOptions extends BaseFormatBytesOptions { | |
| /** | |
| * Interpret the source in base 2, or as base 10 (when false) | |
| */ | |
| sourceIsBinary: boolean; | |
| } | |
| export function byteFormat( | |
| bytes: number, | |
| options: FormatBytesOptions = {}, | |
| ): string { | |
| const { forcePrecision = false, precision = 1, useBinary = false } = options; | |
| const { unit, value, hasRemainder } = byteFormatVerbose(bytes, useBinary); | |
| let asString = value.toString(10); | |
| if (forcePrecision || hasRemainder) { | |
| asString = value.toFixed(precision); | |
| } | |
| return asString + unit; | |
| } | |
| /** | |
| * Format bytes with the semantic consumer byte units (KB, MB, GB, etc.), | |
| * but still interpret them precisely in base 2 or base 10. | |
| */ | |
| export function byteFormatSemantically( | |
| bytes: number, | |
| options: FormatBytesSemanticallyOptions, | |
| ) { | |
| const { forcePrecision = false, precision = 1, sourceIsBinary } = options; | |
| const { unit, value, hasRemainder } = byteFormatVerbose( | |
| bytes, | |
| sourceIsBinary, | |
| ); | |
| const semanticUnit = sourceIsBinary ? byteTypeToDecimalType(unit) : unit; | |
| let asString = value.toString(10); | |
| if (forcePrecision || hasRemainder) { | |
| asString = value.toFixed(precision); | |
| } | |
| return asString + semanticUnit; | |
| } | |
| export function byteFormatVerbose( | |
| bytes: number, | |
| useBinary = false, | |
| ): { | |
| value: number; | |
| unit: ByteType; | |
| hasRemainder: boolean; | |
| bytesRemainder: number; | |
| } { | |
| // const originalBytes = bytes; | |
| bytes = Math.trunc(bytes); | |
| let base = ByteUnit.B; | |
| let unit: ByteType = "B"; | |
| if (useBinary) { | |
| switch (true) { | |
| case bytes < ByteUnit.KiB: | |
| base = ByteUnit.B; | |
| unit = "B"; | |
| break; | |
| case bytes < ByteUnit.MiB: | |
| base = ByteUnit.KiB; | |
| unit = "KiB"; | |
| break; | |
| case bytes < ByteUnit.GiB: | |
| base = ByteUnit.MiB; | |
| unit = "MiB"; | |
| break; | |
| case bytes < ByteUnit.TiB: | |
| base = ByteUnit.GiB; | |
| unit = "GiB"; | |
| break; | |
| case bytes < ByteUnit.PiB: | |
| base = ByteUnit.TiB; | |
| unit = "TiB"; | |
| break; | |
| case bytes < ByteUnit.EiB: | |
| base = ByteUnit.PiB; | |
| unit = "PiB"; | |
| break; | |
| case bytes < ByteUnit.ZiB: | |
| base = ByteUnit.EiB; | |
| unit = "EiB"; | |
| break; | |
| case bytes < ByteUnit.YiB: | |
| base = ByteUnit.ZiB; | |
| unit = "ZiB"; | |
| break; | |
| case bytes >= ByteUnit.YiB: | |
| base = ByteUnit.YiB; | |
| unit = "YiB"; | |
| break; | |
| } | |
| } else { | |
| switch (true) { | |
| case bytes < ByteUnit.KB: | |
| base = ByteUnit.B; | |
| unit = "B"; | |
| break; | |
| case bytes < ByteUnit.MB: | |
| base = ByteUnit.KB; | |
| unit = "KB"; | |
| break; | |
| case bytes < ByteUnit.GB: | |
| base = ByteUnit.MB; | |
| unit = "MB"; | |
| break; | |
| case bytes < ByteUnit.TB: | |
| base = ByteUnit.GB; | |
| unit = "GB"; | |
| break; | |
| case bytes < ByteUnit.PB: | |
| base = ByteUnit.TB; | |
| unit = "TB"; | |
| break; | |
| case bytes < ByteUnit.EB: | |
| base = ByteUnit.PB; | |
| unit = "PB"; | |
| break; | |
| case bytes < ByteUnit.ZB: | |
| base = ByteUnit.EB; | |
| unit = "EB"; | |
| break; | |
| case bytes < ByteUnit.YB: | |
| base = ByteUnit.ZB; | |
| unit = "ZB"; | |
| break; | |
| case bytes >= ByteUnit.YB: | |
| base = ByteUnit.YB; | |
| unit = "YB"; | |
| break; | |
| } | |
| } | |
| const value = bytes / base; | |
| const bytesRemainder = bytes % base; | |
| const hasRemainder = bytesRemainder > 0; | |
| return { | |
| bytesRemainder, | |
| hasRemainder, | |
| unit, | |
| value, | |
| }; | |
| } | |
| export function toBytes(b: number, unit: ByteType) { | |
| switch (unit) { | |
| default: | |
| return expectNever(unit, `Unknown byte type: "${unit}"`); | |
| case "B": | |
| case "KiB": | |
| case "MiB": | |
| case "GiB": | |
| case "TiB": | |
| case "PiB": | |
| case "EiB": | |
| case "ZiB": | |
| case "YiB": | |
| case "KB": | |
| case "MB": | |
| case "GB": | |
| case "TB": | |
| case "PB": | |
| case "EB": | |
| case "ZB": | |
| case "YB": | |
| return b * ByteUnit[unit]; | |
| } | |
| } | |
| export class ByteValue { | |
| readonly bytes: number; | |
| constructor(b: number, unit: ByteType = "B") { | |
| this.bytes = Math.trunc(toBytes(b, unit)); | |
| } | |
| valueOf(): number { | |
| return this.bytes; | |
| } | |
| toString(options?: FormatBytesOptions): string { | |
| return byteFormat(this.bytes, options); | |
| } | |
| } | |
| function byteTypeToDecimalType(unit: ByteType): DecimalByteType { | |
| switch (unit) { | |
| default: | |
| expectNever(unit); | |
| case "KiB": | |
| return "KB"; | |
| case "MiB": | |
| return "MB"; | |
| case "GiB": | |
| return "GB"; | |
| case "TiB": | |
| return "TB"; | |
| case "PiB": | |
| return "PB"; | |
| case "EiB": | |
| return "EB"; | |
| case "ZiB": | |
| return "ZB"; | |
| case "YiB": | |
| return "YB"; | |
| case "B": | |
| case "KB": | |
| case "MB": | |
| case "GB": | |
| case "TB": | |
| case "PB": | |
| case "EB": | |
| case "ZB": | |
| case "YB": | |
| return unit; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| enum Significand { | |
| Billion = 1_000_000_000, | |
| HundredMillion = 100_000_000, | |
| Million = 1_000_000, | |
| HundredThousand = 100_000, | |
| Thousand = 1_000, | |
| Hundred = 100, | |
| } | |
| function newAbbreviator( | |
| factor1: number, | |
| factor2: number, | |
| suffix: string = "", | |
| ): (n: number) => string { | |
| return function abbr(n) { | |
| const mod1 = n % factor1; | |
| const n1 = (n - mod1) / factor1; | |
| const mod2 = mod1 % factor2; | |
| const n2 = (mod1 - mod2) / factor2; | |
| if (n2 > 0) { | |
| return `${n1}.${n2}${suffix}`; | |
| } | |
| return `${n1}${suffix}`; | |
| }; | |
| } | |
| const abbreviator = { | |
| billion: newAbbreviator(Significand.Billion, Significand.HundredMillion, "b"), | |
| million: newAbbreviator( | |
| Significand.Million, | |
| Significand.HundredThousand, | |
| "m", | |
| ), | |
| thousand: newAbbreviator(Significand.Thousand, Significand.Hundred, "k"), | |
| }; | |
| export function abbreviateNumber(number: number): string { | |
| if (!Number.isInteger(number)) { | |
| throw new Error(`Invalid integer for numeric abbreviation (${number})`); | |
| } | |
| let sign = ""; | |
| if (number < 0) { | |
| sign += "-"; | |
| } | |
| number = Math.abs(number); | |
| switch (true) { | |
| case number >= Significand.Billion: | |
| return sign + abbreviator.billion(number); | |
| case number >= Significand.Million: | |
| return sign + abbreviator.million(number); | |
| case number >= Significand.Thousand: | |
| return sign + abbreviator.thousand(number); | |
| default: | |
| return `${sign}${number}`; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const charMap = JSON.parse( | |
| '{"$":"dollar","%":"percent","&":"and","<":"less",">":"greater","|":"or","¢":"cent","£":"pound","¤":"currency","¥":"yen","©":"(c)","ª":"a","®":"(r)","º":"o","À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","Æ":"AE","Ç":"C","È":"E","É":"E","Ê":"E","Ë":"E","Ì":"I","Í":"I","Î":"I","Ï":"I","Ð":"D","Ñ":"N","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","Ù":"U","Ú":"U","Û":"U","Ü":"U","Ý":"Y","Þ":"TH","ß":"ss","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","æ":"ae","ç":"c","è":"e","é":"e","ê":"e","ë":"e","ì":"i","í":"i","î":"i","ï":"i","ð":"d","ñ":"n","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","ù":"u","ú":"u","û":"u","ü":"u","ý":"y","þ":"th","ÿ":"y","Ā":"A","ā":"a","Ă":"A","ă":"a","Ą":"A","ą":"a","Ć":"C","ć":"c","Č":"C","č":"c","Ď":"D","ď":"d","Đ":"DJ","đ":"dj","Ē":"E","ē":"e","Ė":"E","ė":"e","Ę":"e","ę":"e","Ě":"E","ě":"e","Ğ":"G","ğ":"g","Ģ":"G","ģ":"g","Ĩ":"I","ĩ":"i","Ī":"i","ī":"i","Į":"I","į":"i","İ":"I","ı":"i","Ķ":"k","ķ":"k","Ļ":"L","ļ":"l","Ľ":"L","ľ":"l","Ł":"L","ł":"l","Ń":"N","ń":"n","Ņ":"N","ņ":"n","Ň":"N","ň":"n","Ő":"O","ő":"o","Œ":"OE","œ":"oe","Ŕ":"R","ŕ":"r","Ř":"R","ř":"r","Ś":"S","ś":"s","Ş":"S","ş":"s","Š":"S","š":"s","Ţ":"T","ţ":"t","Ť":"T","ť":"t","Ũ":"U","ũ":"u","Ū":"u","ū":"u","Ů":"U","ů":"u","Ű":"U","ű":"u","Ų":"U","ų":"u","Ŵ":"W","ŵ":"w","Ŷ":"Y","ŷ":"y","Ÿ":"Y","Ź":"Z","ź":"z","Ż":"Z","ż":"z","Ž":"Z","ž":"z","ƒ":"f","Ơ":"O","ơ":"o","Ư":"U","ư":"u","Lj":"LJ","lj":"lj","Nj":"NJ","nj":"nj","Ș":"S","ș":"s","Ț":"T","ț":"t","˚":"o","Ά":"A","Έ":"E","Ή":"H","Ί":"I","Ό":"O","Ύ":"Y","Ώ":"W","ΐ":"i","Α":"A","Β":"B","Γ":"G","Δ":"D","Ε":"E","Ζ":"Z","Η":"H","Θ":"8","Ι":"I","Κ":"K","Λ":"L","Μ":"M","Ν":"N","Ξ":"3","Ο":"O","Π":"P","Ρ":"R","Σ":"S","Τ":"T","Υ":"Y","Φ":"F","Χ":"X","Ψ":"PS","Ω":"W","Ϊ":"I","Ϋ":"Y","ά":"a","έ":"e","ή":"h","ί":"i","ΰ":"y","α":"a","β":"b","γ":"g","δ":"d","ε":"e","ζ":"z","η":"h","θ":"8","ι":"i","κ":"k","λ":"l","μ":"m","ν":"n","ξ":"3","ο":"o","π":"p","ρ":"r","ς":"s","σ":"s","τ":"t","υ":"y","φ":"f","χ":"x","ψ":"ps","ω":"w","ϊ":"i","ϋ":"y","ό":"o","ύ":"y","ώ":"w","Ё":"Yo","Ђ":"DJ","Є":"Ye","І":"I","Ї":"Yi","Ј":"J","Љ":"LJ","Њ":"NJ","Ћ":"C","Џ":"DZ","А":"A","Б":"B","В":"V","Г":"G","Д":"D","Е":"E","Ж":"Zh","З":"Z","И":"I","Й":"J","К":"K","Л":"L","М":"M","Н":"N","О":"O","П":"P","Р":"R","С":"S","Т":"T","У":"U","Ф":"F","Х":"H","Ц":"C","Ч":"Ch","Ш":"Sh","Щ":"Sh","Ъ":"U","Ы":"Y","Ь":"","Э":"E","Ю":"Yu","Я":"Ya","а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ж":"zh","з":"z","и":"i","й":"j","к":"k","л":"l","м":"m","н":"n","о":"o","п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"c","ч":"ch","ш":"sh","щ":"sh","ъ":"u","ы":"y","ь":"","э":"e","ю":"yu","я":"ya","ё":"yo","ђ":"dj","є":"ye","і":"i","ї":"yi","ј":"j","љ":"lj","њ":"nj","ћ":"c","ѝ":"u","џ":"dz","Ґ":"G","ґ":"g","Ғ":"GH","ғ":"gh","Қ":"KH","қ":"kh","Ң":"NG","ң":"ng","Ү":"UE","ү":"ue","Ұ":"U","ұ":"u","Һ":"H","һ":"h","Ә":"AE","ә":"ae","Ө":"OE","ө":"oe","฿":"baht","ა":"a","ბ":"b","გ":"g","დ":"d","ე":"e","ვ":"v","ზ":"z","თ":"t","ი":"i","კ":"k","ლ":"l","მ":"m","ნ":"n","ო":"o","პ":"p","ჟ":"zh","რ":"r","ს":"s","ტ":"t","უ":"u","ფ":"f","ქ":"k","ღ":"gh","ყ":"q","შ":"sh","ჩ":"ch","ც":"ts","ძ":"dz","წ":"ts","ჭ":"ch","ხ":"kh","ჯ":"j","ჰ":"h","Ẁ":"W","ẁ":"w","Ẃ":"W","ẃ":"w","Ẅ":"W","ẅ":"w","ẞ":"SS","Ạ":"A","ạ":"a","Ả":"A","ả":"a","Ấ":"A","ấ":"a","Ầ":"A","ầ":"a","Ẩ":"A","ẩ":"a","Ẫ":"A","ẫ":"a","Ậ":"A","ậ":"a","Ắ":"A","ắ":"a","Ằ":"A","ằ":"a","Ẳ":"A","ẳ":"a","Ẵ":"A","ẵ":"a","Ặ":"A","ặ":"a","Ẹ":"E","ẹ":"e","Ẻ":"E","ẻ":"e","Ẽ":"E","ẽ":"e","Ế":"E","ế":"e","Ề":"E","ề":"e","Ể":"E","ể":"e","Ễ":"E","ễ":"e","Ệ":"E","ệ":"e","Ỉ":"I","ỉ":"i","Ị":"I","ị":"i","Ọ":"O","ọ":"o","Ỏ":"O","ỏ":"o","Ố":"O","ố":"o","Ồ":"O","ồ":"o","Ổ":"O","ổ":"o","Ỗ":"O","ỗ":"o","Ộ":"O","ộ":"o","Ớ":"O","ớ":"o","Ờ":"O","ờ":"o","Ở":"O","ở":"o","Ỡ":"O","ỡ":"o","Ợ":"O","ợ":"o","Ụ":"U","ụ":"u","Ủ":"U","ủ":"u","Ứ":"U","ứ":"u","Ừ":"U","ừ":"u","Ử":"U","ử":"u","Ữ":"U","ữ":"u","Ự":"U","ự":"u","Ỳ":"Y","ỳ":"y","Ỵ":"Y","ỵ":"y","Ỷ":"Y","ỷ":"y","Ỹ":"Y","ỹ":"y","‘":"\'","’":"\'","“":"\\"","”":"\\"","†":"+","•":"*","…":"...","₠":"ecu","₢":"cruzeiro","₣":"french franc","₤":"lira","₥":"mill","₦":"naira","₧":"peseta","₨":"rupee","₩":"won","₪":"new shequel","₫":"dong","€":"euro","₭":"kip","₮":"tugrik","₯":"drachma","₰":"penny","₱":"peso","₲":"guarani","₳":"austral","₴":"hryvnia","₵":"cedi","₸":"kazakhstani tenge","₹":"indian rupee","₽":"russian ruble","₿":"bitcoin","℠":"sm","™":"tm","∂":"d","∆":"delta","∑":"sum","∞":"infinity","♥":"love","元":"yuan","円":"yen","﷼":"rial"}', | |
| ); | |
| export interface SlugifyOptions { | |
| replacement?: string; | |
| remove?: RegExp; | |
| lower?: boolean; | |
| } | |
| export function slugify(string: string, options: SlugifyOptions = {}) { | |
| const slug = string | |
| .split("") | |
| .reduce(function (result, ch) { | |
| return ( | |
| result + | |
| (charMap[ch] || ch).replace( | |
| options.remove || /[^\w\s$*_+~.()'"!\-:@]/g, | |
| "", | |
| ) | |
| ); | |
| }, "") | |
| .trim() | |
| .replace(/[-\s]+/g, options.replacement || "-"); | |
| return options.lower ? slug.toLowerCase() : slug; | |
| } | |
| export function extendCharMap(customMap: Record<string, string>) { | |
| for (const key in customMap) { | |
| charMap[key] = customMap[key]; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| type Char = string; | |
| const punctuation: Record<Char, true | undefined> = { | |
| "!": true, | |
| ".": true, | |
| "?": true, | |
| }; | |
| //* add more to union if needed | |
| export type ListFormatType = "conjunction" | "disjunction" | "unit"; | |
| export type ListFormatLocaleMatcher = "lookup" | "best fit"; | |
| export type ListFormatStyle = "long" | "short" | "narrow"; | |
| export type ListFormatOptions = | |
| | { | |
| type: "unit"; | |
| localeMatcher?: ListFormatLocaleMatcher; | |
| style?: ListFormatStyle; | |
| } | |
| | { | |
| type: Exclude<ListFormatType, "unit">; | |
| localeMatcher?: ListFormatLocaleMatcher; | |
| style?: Extract<ListFormatStyle, "long">; | |
| } | |
| | { | |
| type?: ListFormatType; | |
| localeMatcher?: ListFormatLocaleMatcher; | |
| style?: ListFormatStyle; | |
| }; | |
| export interface ListFormatter { | |
| format(list: string[]): string; | |
| } | |
| export function ListFormat( | |
| locale: string | string[], | |
| options: ListFormatOptions, | |
| ): ListFormatter { | |
| if (locale != "en") { | |
| throw new Error( | |
| `Polyfill for Intl.ListFormat does not support any locales besides 'en' (${locale}).`, | |
| ); | |
| } | |
| const { style = "long", type = "conjunction" } = options; | |
| if (type !== "unit" && (style == "narrow" || style == "short")) { | |
| throw new Error(`Invalid type & style combination (${style}, ${type}).`); | |
| } | |
| return { | |
| format(list) { | |
| return listJoin({ style, type }, ...list); | |
| }, | |
| }; | |
| } | |
| type ListJoinOptions = Required< | |
| NonNullable<Pick<ListFormatOptions, "type" | "style">> | |
| >; | |
| function listJoin(options: ListJoinOptions, ...words: string[]): string { | |
| const { type, style } = options; | |
| const conjunction = | |
| type === "conjunction" ? "and" : type === "disjunction" ? "or" : " "; | |
| switch (words.length) { | |
| case 0: { | |
| return ""; | |
| } | |
| case 1: { | |
| return words.join(""); | |
| } | |
| case 2: { | |
| return words.join(` ${conjunction} `); | |
| } | |
| default: { | |
| switch (style) { | |
| default: | |
| case "long": | |
| const lastWord = words.pop(); | |
| return `${words.join(`, `)}, ${conjunction} ${lastWord}`; | |
| case "narrow": | |
| return words.join(`, `); | |
| case "short": | |
| return words.join(" "); | |
| } | |
| } | |
| } | |
| } | |
| export function capitalize(s: string): string { | |
| // Use spread to get UTF-8 graphemes | |
| const [letter, ...word] = s; | |
| return [(letter ?? "").toLocaleUpperCase(), ...word].join(""); | |
| } | |
| export function possessive(noun: string): string { | |
| if (lastByte(noun) === "s") { | |
| return `${noun}'`; | |
| } | |
| return `${noun}'s`; | |
| } | |
| const esPlural = /(?:ss|s|sh|ch|x|z)$/i; | |
| type Word = string; | |
| type Suffix = string; | |
| const endingsForSZ: Record<Word, Suffix> = { | |
| fez: "zes", | |
| gas: "ses", | |
| }; | |
| const fePlural = /(fe|f)$/i; | |
| const feExceptions = new Set(["roof", "belief", "chef", "chief"]); | |
| /* spell-checker: disable */ | |
| const yConsonantPlural = /([bcdfghjklmnpqrstvwx])y$/i; | |
| // const yVowelPlural = /([aeiou]y)$/i; | |
| /* spell-checker: enable */ | |
| /** | |
| * https://www.grammarly.com/blog/plural-nouns/ | |
| */ | |
| export function pluralize(word: Word, count: number): string { | |
| if (count === 1) { | |
| return word; | |
| } | |
| const lc = word.toLocaleLowerCase(); | |
| if (Object.prototype.hasOwnProperty.call(endingsForSZ, lc)) { | |
| return word + endingsForSZ[lc]; | |
| } | |
| if (esPlural.test(word)) { | |
| return word + "es"; | |
| } | |
| if (fePlural.test(word) && !feExceptions.has(lc)) { | |
| return word.replace(fePlural, "ves"); | |
| } | |
| if (yConsonantPlural.test(word)) { | |
| return word.replace(yConsonantPlural, (_, consonant) => consonant + "ies"); | |
| } | |
| return word + "s"; | |
| } | |
| /** | |
| * Returns the last byte of a string or an empty string. | |
| */ | |
| export function lastByte(s: string): Char { | |
| const l = s.length; | |
| return l === 0 ? "" : s[l - 1]!; | |
| } | |
| /** | |
| * Returns the last grapheme of a string or an empty string. | |
| */ | |
| export function lastChar(s: string): Char { | |
| let char = ""; | |
| Array.from(s, (val) => { | |
| char = val; | |
| }); | |
| return char; | |
| } | |
| export function fullStop(sentence: string): string { | |
| if (punctuation[lastByte(sentence)]) { | |
| return sentence; | |
| } | |
| return sentence + "."; | |
| } | |
| export function toSentence(s: string): string { | |
| return fullStop(capitalize(s)); | |
| } | |
| export function withLengthGreaterThan(n: number) { | |
| return (x: string) => x.length > n; | |
| } | |
| export function trimStart(s: string, cutset: string): string { | |
| if (cutset === "" || s === "") { | |
| return s; | |
| } | |
| // Convert the input and the cutset to graphemes | |
| const chars = Array.from(s); | |
| const remove = new Set(cutset); | |
| for (const [i, char] of chars.entries()) { | |
| if (!remove.has(char)) { | |
| return chars.slice(i).join(""); | |
| } | |
| } | |
| return ""; | |
| } | |
| export function trimEnd(s: string, cutset: string): string { | |
| cutset = [...cutset].join("|"); | |
| return s.replace(new RegExp(cutset + "+$"), ""); | |
| } | |
| /** | |
| * Manual text hyphenation and multi-line truncation. | |
| */ | |
| export function lineClamp(str: string, maxChars: number) { | |
| let buf = ""; | |
| let count = 0; | |
| let br = 0; | |
| let delta = 0; | |
| const pattern = /\s/; | |
| for (const char of str) { | |
| count++; | |
| if (count > maxChars) { | |
| break; | |
| } | |
| // Capture spaces that are natural line break points. | |
| if (pattern.test(char)) { | |
| br = count; | |
| } | |
| // How long since the last natural break point? | |
| delta = count - br; | |
| // Every `n` characters insert a break & increment the char count. | |
| if (delta > 0 && delta % 25 === 0) { | |
| buf += "-"; | |
| count++; | |
| } | |
| buf += char; | |
| } | |
| return buf; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { pluralize } from "./strings"; | |
| /** | |
| * Marker for durations, which are always represented in milliseconds. | |
| */ | |
| export type DurationUnit = number; | |
| /** | |
| * A duration is just a time unit in milliseconds. | |
| */ | |
| export const enum Duration { | |
| Millisecond = 1, | |
| Microsecond = Duration.Millisecond / 1000, | |
| Second = Duration.Millisecond * 1000, | |
| Minute = Duration.Second * 60, | |
| Hour = Duration.Minute * 60, | |
| Day = Duration.Hour * 24, | |
| Week = Duration.Day * 7, | |
| } | |
| function createConverter(targetTimeUnit: number): (duration: number) => number { | |
| return (duration) => duration / targetTimeUnit; | |
| } | |
| /** | |
| * A collection of unit converters for a Duration. | |
| */ | |
| export const durationTo = { | |
| microsecond: createConverter(Duration.Microsecond), | |
| millisecond: createConverter(Duration.Millisecond), | |
| second: createConverter(Duration.Second), | |
| minute: createConverter(Duration.Minute), | |
| hour: createConverter(Duration.Hour), | |
| day: createConverter(Duration.Day), | |
| week: createConverter(Duration.Week), | |
| /* Months not implemented because of length variability */ | |
| /* Years not implemented because of leap years */ | |
| }; | |
| export function stringifyDuration(duration: number): string { | |
| switch (true) { | |
| default: | |
| return `${durationTo.week(duration).toFixed(2)}wk`; | |
| case duration < Duration.Millisecond: | |
| return `${durationTo.microsecond(duration).toFixed(2)}µs`; | |
| case duration < Duration.Second: | |
| return `${duration.toFixed(2)}ms`; | |
| case duration < Duration.Minute: | |
| return `${durationTo.second(duration).toFixed(2)}sec`; | |
| case duration < Duration.Hour: | |
| return `${durationTo.minute(duration).toFixed(2)}min`; | |
| case duration < Duration.Day: | |
| return `${durationTo.hour(duration).toFixed(2)}hr`; | |
| case duration < Duration.Week: | |
| return `${durationTo.day(duration).toFixed(2)}day`; | |
| } | |
| } | |
| /** | |
| * The standard date format used across pictalk when serializing dates to strings. | |
| */ | |
| export const Iso8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/; | |
| /** | |
| * Like a `sleep` function in some languages, | |
| * `timeAfter` returns a promise that resolves once the given | |
| * duration has passed. Uses `setTimeout` internally. | |
| */ | |
| export function timeAfter(duration: number): Promise<void> { | |
| return new Promise((r) => setTimeout(r, duration)); | |
| } | |
| interface SemanticEpoch<Label extends string> { | |
| label: Label; | |
| matches(unixMs: number, ref?: Date): boolean; | |
| } | |
| export const TodayEpoch: SemanticEpoch<"Today"> = { | |
| label: "Today", | |
| matches(unix, ref = new Date()) { | |
| let elapsed = 0; | |
| elapsed += Duration.Millisecond * ref.getMilliseconds(); | |
| elapsed += Duration.Second * ref.getSeconds(); | |
| elapsed += Duration.Minute * ref.getMinutes(); | |
| elapsed += Duration.Hour * ref.getHours(); | |
| return unix >= ref.getTime() - elapsed; | |
| }, | |
| }; | |
| export const YesterdayEpoch: SemanticEpoch<"Yesterday"> = { | |
| label: "Yesterday", | |
| matches(unix, ref = new Date()) { | |
| let elapsed = 0; | |
| elapsed += Duration.Millisecond * ref.getMilliseconds(); | |
| elapsed += Duration.Second * ref.getSeconds(); | |
| elapsed += Duration.Minute * ref.getMinutes(); | |
| elapsed += Duration.Hour * ref.getHours(); | |
| // Ensure not today | |
| if (unix >= ref.getTime() - elapsed) { | |
| return false; | |
| } | |
| elapsed += Duration.Day; | |
| return unix >= ref.getTime() - elapsed; | |
| }, | |
| }; | |
| export const ThisWeekEpoch: SemanticEpoch<"This Week"> = { | |
| label: "This Week", | |
| matches(unix, ref = new Date()) { | |
| let elapsed = 0; | |
| elapsed += Duration.Millisecond * ref.getMilliseconds(); | |
| elapsed += Duration.Second * ref.getSeconds(); | |
| elapsed += Duration.Minute * ref.getMinutes(); | |
| elapsed += Duration.Hour * ref.getHours(); | |
| elapsed += Duration.Day * ref.getDay(); | |
| return unix >= ref.getTime() - elapsed; | |
| }, | |
| }; | |
| export const ThisMonthEpoch: SemanticEpoch<"This Month"> = { | |
| label: "This Month", | |
| matches(unix, ref = new Date()) { | |
| let elapsed = 0; | |
| elapsed += Duration.Millisecond * ref.getMilliseconds(); | |
| elapsed += Duration.Second * ref.getSeconds(); | |
| elapsed += Duration.Minute * ref.getMinutes(); | |
| elapsed += Duration.Hour * ref.getHours(); | |
| elapsed += Duration.Day * ref.getDate() - 1; | |
| return unix >= ref.getTime() - elapsed; | |
| }, | |
| }; | |
| export const OlderEpoch: SemanticEpoch<"Older"> = { | |
| label: "Older", | |
| matches(unix, ref = new Date()) { | |
| return unix > ref.getTime(); | |
| }, | |
| }; | |
| export const Epochs = { | |
| [TodayEpoch.label]: TodayEpoch, | |
| [YesterdayEpoch.label]: YesterdayEpoch, | |
| [ThisWeekEpoch.label]: ThisWeekEpoch, | |
| [ThisMonthEpoch.label]: ThisMonthEpoch, | |
| [OlderEpoch.label]: OlderEpoch, | |
| }; | |
| export const EpochLabels = [ | |
| TodayEpoch.label, | |
| YesterdayEpoch.label, | |
| ThisWeekEpoch.label, | |
| ThisMonthEpoch.label, | |
| OlderEpoch.label, | |
| ]; | |
| export function whichEpoch(unixMs: number, ref: Date = new Date()): string { | |
| switch (true) { | |
| default: | |
| return OlderEpoch.label; | |
| case unixMs > ref.getTime(): | |
| throw new Error( | |
| `cannot match SemanticEpoch from the future (${unixMs}, ${ref})`, | |
| ); | |
| case TodayEpoch.matches(unixMs, ref): | |
| return TodayEpoch.label; | |
| case YesterdayEpoch.matches(unixMs, ref): | |
| return YesterdayEpoch.label; | |
| case ThisWeekEpoch.matches(unixMs, ref): | |
| return ThisWeekEpoch.label; | |
| case ThisMonthEpoch.matches(unixMs, ref): | |
| return ThisMonthEpoch.label; | |
| } | |
| } | |
| interface SemanticReferenceTime<Label extends string> | |
| extends SemanticEpoch<Label> { | |
| baseUnit: number; | |
| unitsSince(unixMs: number, ref?: Date): number; | |
| } | |
| /** | |
| * A time within a minute of the reference. | |
| */ | |
| export const SecondsAgoReferenceTime: SemanticReferenceTime<"Seconds Ago"> = { | |
| label: "Seconds Ago", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Minute >= ref.getTime() - unixMs; | |
| }, | |
| baseUnit: Duration.Second, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((ref.getTime() - unixMs) / SecondsAgoReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within an hour of the reference. | |
| */ | |
| export const MinutesAgoReferenceTime: SemanticReferenceTime<"Minutes Ago"> = { | |
| label: "Minutes Ago", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Hour >= ref.getTime() - unixMs; | |
| }, | |
| baseUnit: Duration.Minute, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((ref.getTime() - unixMs) / MinutesAgoReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within a day of the reference. | |
| */ | |
| export const HoursAgoReferenceTime: SemanticReferenceTime<"Hours Ago"> = { | |
| label: "Hours Ago", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Hour * 24 >= ref.getTime() - unixMs; | |
| }, | |
| baseUnit: Duration.Hour, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((ref.getTime() - unixMs) / HoursAgoReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within a week of the reference. | |
| */ | |
| export const DaysAgoReferenceTime: SemanticReferenceTime<"Days Ago"> = { | |
| label: "Days Ago", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Week >= ref.getTime() - unixMs; | |
| }, | |
| baseUnit: Duration.Day, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((ref.getTime() - unixMs) / DaysAgoReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within 4 weeks of the reference. | |
| */ | |
| export const WeeksAgoReferenceTime: SemanticReferenceTime<"Weeks Ago"> = { | |
| label: "Weeks Ago", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Week * 4 >= ref.getTime() - unixMs; | |
| }, | |
| baseUnit: Duration.Week, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((ref.getTime() - unixMs) / WeeksAgoReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within a year of the reference. | |
| */ | |
| export const MonthsAgoReferenceTime: SemanticReferenceTime<"Months Ago"> = { | |
| label: "Months Ago", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Day * 365 > ref.getTime() - unixMs; | |
| }, | |
| baseUnit: (Duration.Day * 365) / 12, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((ref.getTime() - unixMs) / MonthsAgoReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time a year or more before the reference. | |
| */ | |
| export const YearsAgoReferenceTime: SemanticReferenceTime<"Years Ago"> = { | |
| label: "Years Ago", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Day * 365 <= ref.getTime() - unixMs; | |
| }, | |
| baseUnit: Duration.Day * 365, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((ref.getTime() - unixMs) / YearsAgoReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| export function timeAgoTerse(unixMs: number, ref: Date = new Date()): string { | |
| switch (true) { | |
| default: | |
| throw new Error(`Invariant`); | |
| case Number.isNaN(unixMs): | |
| throw new Error(`time given is not a number (${unixMs})`); | |
| case unixMs > ref.getTime(): | |
| throw new Error(`cannot render time ago when time is in the future`); | |
| case SecondsAgoReferenceTime.matches(unixMs, ref): | |
| return `${SecondsAgoReferenceTime.unitsSince(unixMs, ref)}s`; | |
| case MinutesAgoReferenceTime.matches(unixMs, ref): | |
| return `${MinutesAgoReferenceTime.unitsSince(unixMs, ref)}m`; | |
| case HoursAgoReferenceTime.matches(unixMs, ref): | |
| return `${HoursAgoReferenceTime.unitsSince(unixMs, ref)}h`; | |
| case DaysAgoReferenceTime.matches(unixMs, ref): | |
| return `${DaysAgoReferenceTime.unitsSince(unixMs, ref)}d`; | |
| case WeeksAgoReferenceTime.matches(unixMs, ref): | |
| return `${WeeksAgoReferenceTime.unitsSince(unixMs, ref)}wk`; | |
| case MonthsAgoReferenceTime.matches(unixMs, ref): | |
| return `${MonthsAgoReferenceTime.unitsSince(unixMs, ref)}mo`; | |
| case YearsAgoReferenceTime.matches(unixMs, ref): | |
| return `${YearsAgoReferenceTime.unitsSince(unixMs, ref)}yr`; | |
| } | |
| } | |
| export function timeAgoVerbose(unixMs: number, ref: Date = new Date()): string { | |
| switch (true) { | |
| default: | |
| throw new Error("Invariant"); | |
| case Number.isNaN(unixMs): | |
| throw new Error("time given is not a number"); | |
| case unixMs > ref.getTime(): | |
| throw new Error("cannot render time ago when time is in the future"); | |
| case SecondsAgoReferenceTime.matches(unixMs, ref): { | |
| const n = SecondsAgoReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("second", n)}`; | |
| } | |
| case MinutesAgoReferenceTime.matches(unixMs, ref): { | |
| const n = MinutesAgoReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("minute", n)}`; | |
| } | |
| case HoursAgoReferenceTime.matches(unixMs, ref): { | |
| const n = HoursAgoReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("hour", n)}`; | |
| } | |
| case DaysAgoReferenceTime.matches(unixMs, ref): { | |
| const n = DaysAgoReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("day", n)}`; | |
| } | |
| case WeeksAgoReferenceTime.matches(unixMs, ref): { | |
| const n = WeeksAgoReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("week", n)}`; | |
| } | |
| case MonthsAgoReferenceTime.matches(unixMs, ref): { | |
| const n = MonthsAgoReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("month", n)}`; | |
| } | |
| case YearsAgoReferenceTime.matches(unixMs, ref): { | |
| const n = YearsAgoReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("year", n)}`; | |
| } | |
| } | |
| } | |
| // Time Formatters (https://tools.ietf.org/html/rfc3339) | |
| // ----------------------------------------------------------------------------- | |
| /** | |
| * Convert a date into an ISO8601 'full-date' string in the form: | |
| * `YYYY-MM-DD` | |
| */ | |
| export function fmtISOFullDate(d: Date = new Date()): string { | |
| preconditionIsValidDate(d); | |
| // "2019-07-10T02:25:12.103Z" | |
| // |2019-07-10| slice[0:10] | |
| return d.toISOString().slice(0, 10); | |
| } | |
| /** | |
| * Convert a date into an ISO8601 'partial-time' string in the form: | |
| * `hh:mm:ss` | |
| */ | |
| export function fmtISOPartialTime(d: Date = new Date()): string { | |
| preconditionIsValidDate(d); | |
| // "2019-07-10T02:25:12.103Z" | |
| // ----------|02:25:12| slice[11:19] | |
| return d.toISOString().slice(11, 19); | |
| } | |
| export function fmtDateLocale( | |
| date: string | number | Date, | |
| { noYear = false } = {}, | |
| ) { | |
| const d = new Date(date); | |
| preconditionIsValidDate(d); | |
| return d.toLocaleDateString("default", { | |
| month: "long", | |
| year: noYear ? undefined : "numeric", | |
| day: "numeric", | |
| }); | |
| } | |
| /** | |
| * Times of day that map to dynamic image assets appropriate for that time. | |
| */ | |
| export enum TimeOfDay { | |
| Sunrise, | |
| Day, | |
| Evening, | |
| Night, | |
| } | |
| export function calcTimeOfDay(d: Date = new Date()): TimeOfDay { | |
| preconditionIsValidDate(d); | |
| switch (d.getHours()) { | |
| default: | |
| throw new Error("Invariant"); | |
| // 10:00pm - 4:00am | |
| case 22: | |
| case 23: | |
| case 0: | |
| case 1: | |
| case 2: | |
| case 3: | |
| case 4: | |
| return TimeOfDay.Night; | |
| // 5:00am - 8:00am | |
| case 5: | |
| case 6: | |
| case 7: | |
| case 8: | |
| return TimeOfDay.Sunrise; | |
| // 9:00am - 5:00pm | |
| case 9: | |
| case 10: | |
| case 11: | |
| case 12: | |
| case 13: | |
| case 14: | |
| case 15: | |
| case 16: | |
| case 17: | |
| return TimeOfDay.Day; | |
| // 6:00pm - 9:00pm | |
| case 18: | |
| case 19: | |
| case 20: | |
| case 21: | |
| return TimeOfDay.Evening; | |
| } | |
| } | |
| function preconditionIsValidDate(d: Date) { | |
| if (Number.isNaN(d.getTime())) { | |
| throw new Error(`Invalid date`); | |
| } | |
| } | |
| /** | |
| * A time within a minute of the reference. | |
| */ | |
| export const SecondsUntilReferenceTime: SemanticReferenceTime<"Seconds Until"> = | |
| { | |
| label: "Seconds Until", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Minute >= unixMs - ref.getTime(); | |
| }, | |
| baseUnit: Duration.Second, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round( | |
| (unixMs - ref.getTime()) / SecondsUntilReferenceTime.baseUnit, | |
| ), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within an hour of the reference. | |
| */ | |
| export const MinutesUntilReferenceTime: SemanticReferenceTime<"Minutes Until"> = | |
| { | |
| label: "Minutes Until", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Hour >= unixMs - ref.getTime(); | |
| }, | |
| baseUnit: Duration.Minute, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round( | |
| (unixMs - ref.getTime()) / MinutesUntilReferenceTime.baseUnit, | |
| ), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within a day of the reference. | |
| */ | |
| export const HoursUntilReferenceTime: SemanticReferenceTime<"Hours Until"> = { | |
| label: "Hours Until", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Hour * 24 >= unixMs - ref.getTime(); | |
| }, | |
| baseUnit: Duration.Hour, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((unixMs - ref.getTime()) / HoursUntilReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within a week of the reference. | |
| */ | |
| export const DaysUntilReferenceTime: SemanticReferenceTime<"Days Until"> = { | |
| label: "Days Until", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Week >= unixMs - ref.getTime(); | |
| }, | |
| baseUnit: Duration.Day, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((unixMs - ref.getTime()) / DaysUntilReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within 4 weeks of the reference. | |
| */ | |
| export const WeeksUntilReferenceTime: SemanticReferenceTime<"Weeks Until"> = { | |
| label: "Weeks Until", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Week * 4 >= unixMs - ref.getTime(); | |
| }, | |
| baseUnit: Duration.Week, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((unixMs - ref.getTime()) / WeeksUntilReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time within a year of the reference. | |
| */ | |
| export const MonthsUntilReferenceTime: SemanticReferenceTime<"Months Until"> = { | |
| label: "Months Until", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Day * 365 > unixMs - ref.getTime(); | |
| }, | |
| baseUnit: (Duration.Day * 365) / 12, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((unixMs - ref.getTime()) / MonthsUntilReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| /** | |
| * A time a year or more before the reference. | |
| */ | |
| export const YearsUntilReferenceTime: SemanticReferenceTime<"Years Until"> = { | |
| label: "Years Until", | |
| matches(unixMs, ref = new Date()) { | |
| return Duration.Day * 365 <= unixMs - ref.getTime(); | |
| }, | |
| baseUnit: Duration.Day * 365, | |
| unitsSince(unixMs, ref = new Date()) { | |
| return Math.max( | |
| 1, | |
| Math.round((unixMs - ref.getTime()) / YearsUntilReferenceTime.baseUnit), | |
| ); | |
| }, | |
| }; | |
| export function timeUntilTerse(unixMs: number, ref: Date = new Date()): string { | |
| switch (true) { | |
| default: | |
| throw new Error(`Invariant`); | |
| case Number.isNaN(unixMs): | |
| throw new Error("time given is not a number"); | |
| case unixMs < ref.getTime(): | |
| throw new Error("cannot render time until when time is in the past"); | |
| case SecondsUntilReferenceTime.matches(unixMs, ref): | |
| return `${SecondsUntilReferenceTime.unitsSince(unixMs, ref)}s`; | |
| case MinutesUntilReferenceTime.matches(unixMs, ref): | |
| return `${MinutesUntilReferenceTime.unitsSince(unixMs, ref)}m`; | |
| case HoursUntilReferenceTime.matches(unixMs, ref): | |
| return `${HoursUntilReferenceTime.unitsSince(unixMs, ref)}h`; | |
| case DaysUntilReferenceTime.matches(unixMs, ref): | |
| return `${DaysUntilReferenceTime.unitsSince(unixMs, ref)}d`; | |
| case WeeksUntilReferenceTime.matches(unixMs, ref): | |
| return `${WeeksUntilReferenceTime.unitsSince(unixMs, ref)}wk`; | |
| case MonthsUntilReferenceTime.matches(unixMs, ref): | |
| return `${MonthsUntilReferenceTime.unitsSince(unixMs, ref)}mo`; | |
| case YearsUntilReferenceTime.matches(unixMs, ref): | |
| return `${YearsUntilReferenceTime.unitsSince(unixMs, ref)}yr`; | |
| } | |
| } | |
| export function timeUntilVerbose( | |
| unixMs: number, | |
| ref: Date = new Date(), | |
| ): string { | |
| switch (true) { | |
| default: | |
| throw new Error("Invariant"); | |
| case Number.isNaN(unixMs): | |
| throw new Error("time given is not a number"); | |
| case unixMs < ref.getTime(): | |
| throw new Error("cannot render time until when time is in the past"); | |
| case SecondsUntilReferenceTime.matches(unixMs, ref): { | |
| const n = SecondsUntilReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("second", n)}`; | |
| } | |
| case MinutesUntilReferenceTime.matches(unixMs, ref): { | |
| const n = MinutesUntilReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("minute", n)}`; | |
| } | |
| case HoursUntilReferenceTime.matches(unixMs, ref): { | |
| const n = HoursUntilReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("hour", n)}`; | |
| } | |
| case DaysUntilReferenceTime.matches(unixMs, ref): { | |
| const n = DaysUntilReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("day", n)}`; | |
| } | |
| case WeeksUntilReferenceTime.matches(unixMs, ref): { | |
| const n = WeeksUntilReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("week", n)}`; | |
| } | |
| case MonthsUntilReferenceTime.matches(unixMs, ref): { | |
| const n = MonthsUntilReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("month", n)}`; | |
| } | |
| case YearsUntilReferenceTime.matches(unixMs, ref): { | |
| const n = YearsUntilReferenceTime.unitsSince(unixMs, ref); | |
| return `${n} ${pluralize("year", n)}`; | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment