Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add versatile duration formatting and parsing function #66

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/__tests__/duration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { formatOrParseDuration } from "../duration";

describe("formatOrParseDuration", () => {
describe("formatDuration", () => {
it("formats duration to hh:mm:ss", () => {
expect(formatOrParseDuration(3661000)).toBe("01:01:01");
});

it("formats duration to mm:ss", () => {
expect(formatOrParseDuration(61000, { format: "mm:ss" })).toBe("01:01");
});

it("formats duration to hh:mm", () => {
expect(formatOrParseDuration(3660000, { format: "hh:mm" })).toBe("01:01");
});

it("formats duration to DD:hh:mm:ss", () => {
expect(formatOrParseDuration(90061000, { format: "DD:hh:mm:ss" })).toBe("01:01:01:01");
});

it("formats duration to DD:hh:mm:ss:SSS", () => {
expect(formatOrParseDuration(90061001, { format: "DD:hh:mm:ss:SSS" })).toBe("01:01:01:01:001");
});

it("formats duration to hh:mm:ss,SSS", () => {
expect(formatOrParseDuration(3661001, { format: "hh:mm:ss,SSS" })).toBe("01:01:01,001");
});

it("throws error for invalid input", () => {
expect(() => formatOrParseDuration("invalid input")).toThrow("Invalid input or options.");
});
});

describe("parseDuration", () => {
it("parses hh:mm:ss to milliseconds", () => {
expect(formatOrParseDuration("01:01:01", { format: "hh:mm:ss", parse: true, locale: "en" })).toBe(3661000);
});

it("parses mm:ss to milliseconds", () => {
expect(formatOrParseDuration("01:01", { format: "mm:ss", parse: true, locale: "en" })).toBe(61000);
});

it("parses hh:mm to milliseconds", () => {
expect(formatOrParseDuration("01:01", { format: "hh:mm", parse: true, locale: "en" })).toBe(3660000);
});

it("parses DD:hh:mm:ss to milliseconds", () => {
expect(formatOrParseDuration("01:01:01:01", { format: "DD:hh:mm:ss", parse: true, locale: "en" })).toBe(90061000);
});

it("parses DD:hh:mm:ss:SSS to milliseconds", () => {
expect(formatOrParseDuration("01:01:01:01:001", { format: "DD:hh:mm:ss:SSS", parse: true, locale: "en" })).toBe(90061001);
});

it("parses hh:mm:ss,SSS to milliseconds", () => {
expect(formatOrParseDuration("01:01:01,001", { format: "hh:mm:ss,SSS", parse: true, locale: "en" })).toBe(3661001);
});

it("throws error for invalid duration string", () => {
expect(() => formatOrParseDuration("invalid input", { format: "hh:mm:ss", parse: true, locale: "en" })).toThrow("Invalid duration string.");
});

it("parses DD:hh:mm:ss with locale 'fr'", () => {
expect(formatOrParseDuration("01:01:01:01", { format: "DD:hh:mm:ss", parse: true, locale: "fr" })).toBe(90061000);
});

it("parses hh:mm:ss,SSS with locale 'de'", () => {
expect(formatOrParseDuration("01:01:01,001", { format: "hh:mm:ss,SSS", parse: true, locale: "de" })).toBe(3661001);
});
});
});
48 changes: 24 additions & 24 deletions src/__tests__/iso8601.spec.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
import { describe, it, expect } from "vitest"
import { iso8601 } from "../iso8601"
import { describe, it, expect } from "vitest";
import { iso8601 } from "../iso8601";

process.env.TZ = "America/New_York"
process.env.TZ = "America/New_York";

describe("validating ISO 8601", () => {
it("validates full dates", () =>
expect(iso8601("2022-01-22 00:00:00")).toBe(true))
expect(iso8601("2022-01-22 00:00:00")).toBe(true));
it("validates full dates with T", () =>
expect(iso8601("2022-01-22T23:59:59")).toBe(true))
it("does allow ancient dates", () =>
expect(iso8601("0032-06-15 00:00:00")).toBe(true))
it("does allow milliseconds", () =>
expect(iso8601("0032-06-15 00:00:00.456")).toBe(true))
it("does now allow 24 hours", () =>
expect(iso8601("2022-01-22 24:00:00")).toBe(false))
it("does now allow 60 minutes", () =>
expect(iso8601("2022-01-22 00:60:00")).toBe(false))
it("does now allow 60 seconds", () =>
expect(iso8601("2022-01-22 00:00:60")).toBe(false))
it("does now allow 13 months", () =>
expect(iso8601("2022-13-22 00:00:00")).toBe(false))
it("does now allow 10,000 years", () =>
expect(iso8601("10000-01-01 00:00:00")).toBe(false))
it("does now allow 40 days", () =>
expect(iso8601("2000-01-40 00:00:00")).toBe(false))
it("allows a lot of decimals", () =>
expect(iso8601("2000-01-30 00:00:00.0000000000")).toBe(true))
})
expect(iso8601("2022-01-22T23:59:59")).toBe(true));
it("allows ancient dates", () =>
expect(iso8601("0032-06-15 00:00:00")).toBe(true));
it("allows milliseconds", () =>
expect(iso8601("0032-06-15 00:00:00.456")).toBe(true));
it("does not allow 24 hours", () =>
expect(iso8601("2022-01-22 24:00:00")).toBe(false));
it("does not allow 60 minutes", () =>
expect(iso8601("2022-01-22 00:60:00")).toBe(false));
it("does not allow 60 seconds", () =>
expect(iso8601("2022-01-22 00:00:60")).toBe(false));
it("does not allow 13 months", () =>
expect(iso8601("2022-13-22 00:00:00")).toBe(false));
it("does not allow 10,000 years", () =>
expect(iso8601("10000-01-01 00:00:00")).toBe(false));
it("does not allow 40 days", () =>
expect(iso8601("2000-01-40 00:00:00")).toBe(false));
it("does not allow more than 3 milliseconds decimals", () =>
expect(iso8601("2000-01-30 00:00:00.000000000")).toBe(false));
});
25 changes: 25 additions & 0 deletions src/__tests__/sameMillisecond.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest"
import { sameMillisecond } from "../sameMillisecond"
process.env.TZ = "America/New_York"

describe("sameMillisecond", () => {
it("can determine two dates are the exact same", () => {
expect(sameMillisecond(new Date(), new Date())).toBe(true)
})

it("can determine string dates", () => {
expect(sameMillisecond("2024-01-01 10:20:30.123", "2024-01-01 10:20:30.123")).toBe(true)
})

it("can determine different dates and time except milliseconds", () => {
expect(sameMillisecond("2024-01-01 10:20:30.123", "2024-02-02 20:10:01.123")).toBe(true)
})

it("can determine different dates and time with different milliseconds", () => {
expect(sameMillisecond("2024-01-01 10:20:30.123", "2024-02-02 20:10:00.456")).toBe(false)
})

it("can determine same date and time with different milliseconds", () => {
expect(sameMillisecond("2024-01-01 10:20:30.123", "2024-01-01 10:20:30.456")).toBe(false)
})
})
19 changes: 11 additions & 8 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
Part,
FilledPart,
Format,
ExtendedDateTimeFormatPartTypesRegistry,
MaybeDateInput,
} from "./types"

Expand Down Expand Up @@ -40,6 +41,7 @@ export const clockAgnostic: FormatPattern[] = [
["m", { minute: "numeric" }],
["ss", { second: "2-digit" }],
["s", { second: "numeric" }],
["SSS", { millisecond: "numeric" }],
["ZZ", { timeZoneName: "long" }],
["Z", { timeZoneName: "short" }],
]
Expand All @@ -55,7 +57,7 @@ const timeZoneTokens = ["Z", "ZZ"] as const
export type TimezoneToken = (typeof timeZoneTokens)[number]

/**
* 24 hour click format patterns.
* 24 hour clock format patterns.
*/
export const clock24: FormatPattern[] = [
["HH", { hour: "2-digit" }],
Expand Down Expand Up @@ -84,6 +86,7 @@ export const fixedLength = {
hh: 2,
mm: 2,
ss: 2,
SSS: 3,
}

/**
Expand All @@ -104,7 +107,7 @@ export function fixedLengthByOffset(offsetString: string): 6 | 5 {
}

/**
* Tokens that are genitive — in that they can have "possession" when used in
* Tokens that are genitive — in that they can have "possession" when used in
* a date phrase, "March’s 4th day" (but not in english).
*
* When computing a range for these, the range can be either genitive or not.
Expand Down Expand Up @@ -140,7 +143,7 @@ export const two = (n: number) => String(n).padStart(2, "0")
* Creates a leading zero string of 4 digits.
* @param n - A number.
*/
export const four = (n: number) => String(n).padStart(2, "0")
export const four = (n: number) => String(n).padStart(4, "0")

/**
* Normalizes a given part to NFKC.
Expand Down Expand Up @@ -183,7 +186,7 @@ export function fill(
if (partName === "hour" && token === "H") {
return value.replace(/^0/, "") || "0"
}
if (["mm", "ss", "MM"].includes(token) && value.length === 1) {
if (["mm", "ss", "SSS", "MM"].includes(token) && value.length === 1) {
// Some tokens are supposed to have leading zeros, but Intl doesn't
// always return them, depending on the locale and the format.
return `0${value}`
Expand Down Expand Up @@ -218,7 +221,7 @@ function createPartMap(
parts: Part[],
locale: string,
genitive = false
): Record<keyof Intl.DateTimeFormatPartTypesRegistry, string> {
): Record<keyof ExtendedDateTimeFormatPartTypesRegistry, string> {
const d = date(inputDate)
const hour12 = parts.filter((part) => part.hour12)
const hour24 = parts.filter((part) => !part.hour12)
Expand All @@ -233,7 +236,7 @@ function createPartMap(
requestedParts.reduce(
(options, part) => {
if (part.partName === "literal") return options
// Side effect! Genitive parts get shoved into a separate array.
// Side effect! Genitive parts get empujadas en un array separado.
if (genitive && genitiveTokens.includes(part.token)) {
genitiveParts.push(part)
}
Expand Down Expand Up @@ -279,9 +282,9 @@ function createPartMap(
if (hour24.length) addValues(hour24)

return valueParts.reduce((map, part) => {
map[part.type] = part.value
map[part.type as keyof ExtendedDateTimeFormatPartTypesRegistry] = part.value
return map
}, {} as Record<keyof Intl.DateTimeFormatPartTypesRegistry, string>)
}, {} as Record<keyof ExtendedDateTimeFormatPartTypesRegistry, string>)
}

/**
Expand Down
5 changes: 2 additions & 3 deletions src/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ export function date(date?: MaybeDateInput): Date {
date = new Date()
}
if (date instanceof Date) {
const d = new Date(date)
d.setMilliseconds(0)
return d
// Preserve the milliseconds from the existing Date object
return new Date(date.getTime())
}
date = date.trim()
if (iso8601(date)) {
Expand Down
114 changes: 114 additions & 0 deletions src/duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Format, FormatToken } from "./types"
import { validate } from "./common"
import { formatStr } from "./formatStr"
import { parts } from "./parts"

interface DurationOptions {
format?: Format // supported and custom formats
parse?: boolean // whether to parse or format
locale?: string // locale for formatting and parsing
}

export function formatOrParseDuration(
input: number | string,
options: DurationOptions = {}
): string | number {
const { format = "hh:mm:ss", parse = false, locale = "en" } = options

// Determine whether to parse or format based on the input type and options.
if (parse && typeof input === "string") {
return parseDuration(input, format, locale)
}

if (!parse && typeof input === "number") {
return formatDuration(input, format, locale)
}

throw new Error("Invalid input or options.")
}

function formatDuration(
durationInMs: number,
format: Format,
locale: string
): string {
const parts: Partial<Record<FormatToken, number>> = {
// Calculate days from milliseconds.
DD: Math.floor(durationInMs / 86400000),
// Calculate hours from remaining milliseconds.
hh: Math.floor((durationInMs % 86400000) / 3600000),
// Calculate minutes from remaining milliseconds.
mm: Math.floor((durationInMs % 3600000) / 60000),
// Calculate seconds from remaining milliseconds.
ss: Math.floor((durationInMs % 60000) / 1000),
// Calculate milliseconds.
SSS: durationInMs % 1000,
}

return formatStr(format, locale).replace(/DD|hh|mm|ss|SSS/g, (match) => {
return String(parts[match as FormatToken]).padStart(
match === "SSS" ? 3 : 2,
"0"
)
})
}

function parseDuration(
durationString: string,
format: Format,
locale: string
): number {
const formatParts = validate(parts(format, locale))
const regexPattern = formatParts
.map((part) => {
if (part.partName === "literal") {
// Escape special regex characters
return part.partValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
if (part.token === "SSS") {
return "(\\d{1,3})"
}
return "(\\d{1,2})"
})
.join("")

const regex = new RegExp(`^${regexPattern}$`)
const matches = durationString.match(regex)

if (!matches) {
throw new Error("Invalid duration string.")
}

const partsValues = matches.slice(1).map(Number)

let durationInMs = 0

let valueIndex = 0
formatParts.forEach((part) => {
if (part.partName === "literal") {
return
}
const value = partsValues[valueIndex++]
switch (part.token) {
case "DD":
durationInMs += value * 86400000
break
case "hh":
durationInMs += value * 3600000
break
case "mm":
durationInMs += value * 60000
break
case "ss":
durationInMs += value * 1000
break
case "SSS":
durationInMs += value
break
default:
throw new Error(`Unknown format token: ${part.token}`)
}
})

return durationInMs
}
9 changes: 2 additions & 7 deletions src/format.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { date } from "./date"
import { parts } from "./parts"
import { fill, getOffsetFormat } from "./common"
import type {
DateInput,
Format,
FormatOptions,
FormatStyle,
Part,
} from "./types"
import type { DateInput, Format, FormatOptions, Part } from "./types"
import { offset } from "./offset"
import { removeOffset } from "./removeOffset"
import { deviceLocale } from "./deviceLocale"
Expand Down Expand Up @@ -36,6 +30,7 @@ import { deviceTZ } from "./deviceTZ"
* mm | The minute 00-59
* s | The second 0-59
* ss | The second 00-59
* SSS | The millisecond 000-999
* a | am/pm
* A | AM/PM
* Z | +0800, +0530, -1345
Expand Down
Loading