Skip to content

Instantly share code, notes, and snippets.

@alexsasharegan
Created November 15, 2025 16:49
Show Gist options
  • Select an option

  • Save alexsasharegan/d08e4c0454c36c11be49b7de4a486c0e to your computer and use it in GitHub Desktop.

Select an option

Save alexsasharegan/d08e4c0454c36c11be49b7de4a486c0e to your computer and use it in GitHub Desktop.
TS toolkit
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;
}
}
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}`;
}
}
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];
}
}
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;
}
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