Skip to content

Commit

Permalink
Merge pull request #23 from bjuppa/plain-time
Browse files Browse the repository at this point in the history
Implement PlainTime
  • Loading branch information
bjuppa authored May 30, 2023
2 parents 07e01dd + dcfc26f commit 806fbdf
Show file tree
Hide file tree
Showing 12 changed files with 387 additions and 56 deletions.
1 change: 1 addition & 0 deletions PlainDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function PlainDate(
{ year = NaN, month = 1, day = 1 }: SloppyDate,
): ComPlainDate {
const utcDate = createUtcInstant({ year, month, day });

if (isNaN(utcDate.valueOf())) {
throw new TypeError(
`Input is not a valid date: ${JSON.stringify({ year, month, day })}`,
Expand Down
136 changes: 136 additions & 0 deletions PlainTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { SloppyTime } from "./support/date-time-types.ts";
import { HOURS_IN_DAY, MS_IN_HOUR } from "./constants.ts";
import { tallyMilliseconds } from "./support/tallyMilliseconds.ts";
import { intlParts } from "./support/intlParts.ts";
import { isTruthy } from "./support/isTruthy.ts";

export type FormatPlainTimeOptions =
& Omit<
Intl.DateTimeFormatOptions,
| "timeZone"
| "timeZoneName"
| "timeStyle"
| "dateStyle"
| "weekday"
| "year"
| "month"
| "day"
| "era"
>
& { timeStyle?: "medium" | "short" };

const intlUtcFormat = Intl.DateTimeFormat("en", {
hourCycle: "h23",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
timeZone: "UTC",
});

/** Describes a basic time-of-day object with minimal properties */
export interface ComPlainTime {
/** Hour (1-23) */
hour: number;
/** Minute (0-59) */
minute: number;
/** Second (0-59) */
second: number;
/** Millisecond (0-999) */
millisecond: number;

/** Tallied milliseconds since 00:00 */
valueOf: () => number;
/** `hh:mm` / `hh:mm:ss` / `hh:mm:ss.sss` */
toString: () => string;
/** `hh:mm` / `hh:mm:ss` / `hh:mm:ss.sss` */
toJSON: () => ReturnType<this["toString"]>;

/**
* Localize the time for display to a user.
*
* Defaults to "short" time-style if no options given.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat | Intl.DateTimeFormat options on MDN}
*/
toLocaleString: (
locale?: Intl.LocalesArgument,
options?: FormatPlainTimeOptions,
) => string;

constructor: PlainTimeFactory<this>;
}

/**
* Describes a factory function that creates plain-time objects.
*/
export interface PlainTimeFactory<T extends ComPlainTime> {
(x: SloppyTime): T;
}

/**
* Factory function for making basic plain-time objects with minimal properties.
*
* @param date A time object with optional properties `hour`, `minute`, `second` & 'millisecond'
* @returns A new immutable plain-time object
*/
export function PlainTime(
{ hour = 0, minute = 0, second = 0, millisecond = 0 }: SloppyTime,
): ComPlainTime {
const ms = tallyMilliseconds({ hour, minute, second, millisecond });

if (ms < 0) {
throw new TypeError(
`Input tally must be positive: ${
JSON.stringify({ hour, minute, second, millisecond })
}`,
);
}
if (ms >= HOURS_IN_DAY * MS_IN_HOUR) {
throw new TypeError(
`Input tally must be less than 24 hours: ${
JSON.stringify({ hour, minute, second, millisecond })
}`,
);
}

const epochUtcDate = new Date(ms);

const plainTime: ComPlainTime = {
constructor: PlainTime,
hour: epochUtcDate.getUTCHours(),
minute: epochUtcDate.getUTCMinutes(),
second: epochUtcDate.getUTCSeconds(),
millisecond: epochUtcDate.getUTCMilliseconds(),

valueOf() {
return ms;
},
toString() {
const parts = intlParts(intlUtcFormat)(epochUtcDate);
return [
parts["hour"],
parts["minute"],
parts["fractionalSecond"] !== "000"
? `${parts["second"]}.${parts["fractionalSecond"]}`
: parts["second"] !== "00"
? parts["second"]
: undefined,
].filter(isTruthy).join(":");
},
toJSON() {
return this.toString();
},

toLocaleString(locale = undefined, options = { timeStyle: "short" }) {
return epochUtcDate.toLocaleTimeString(locale, {
...options,
timeZone: "UTC",
});
},
};

Object.freeze(plainTime);

return plainTime;
}
116 changes: 116 additions & 0 deletions PlainTime_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { PlainTime } from "./PlainTime.ts";
import { MS_IN_HOUR, MS_IN_MINUTE } from "./constants.ts";
import { assertEquals, assertThrows } from "./dev_deps.ts";

Deno.test("factory accepts number time parts", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
second: 4,
millisecond: 5,
});

assertEquals(String(plainTime), "02:03:04.005");
});

Deno.test("factory accepts string time parts", () => {
const plainTime = PlainTime({
hour: "02",
minute: "03",
second: "04",
millisecond: "005",
});

assertEquals(String(plainTime), "02:03:04.005");
});

Deno.test("factory throws error when tally is negative", () => {
assertThrows(() => {
PlainTime({ hour: 1, minute: -61 });
});
});

Deno.test("factory throws error when tally is 24 hours", () => {
assertThrows(() => {
PlainTime({ hour: 23, minute: 60 });
});
});

Deno.test("enumerable properties can not be set", async (t) => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
second: 4,
millisecond: 5,
});

for (const property in plainTime) {
await t.step(`property '${property}'`, () => {
assertThrows(() => {
// @ts-ignore: Bypass readonly
plainDate[property] = 1;
});
});
}
});

Deno.test("value conversion returns number of milliseconds from 00:00", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
});
const ms = 2 * MS_IN_HOUR + 3 * MS_IN_MINUTE;

assertEquals(plainTime.valueOf(), ms);
assertEquals(Number(plainTime), ms);
});

Deno.test("string representation is short for 0 seconds", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
});
const time = "02:03";

assertEquals(plainTime.toString(), time);
assertEquals(plainTime.toJSON(), time);
assertEquals(String(plainTime), time);
});

Deno.test("string representation is medium for 0 milliseconds", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
second: 4,
});
const time = "02:03:04";

assertEquals(plainTime.toString(), time);
assertEquals(plainTime.toJSON(), time);
assertEquals(String(plainTime), time);
});

Deno.test("string representation is long for fractional seconds", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
millisecond: 1,
});
const time = "02:03:00.001";

assertEquals(plainTime.toString(), time);
assertEquals(plainTime.toJSON(), time);
assertEquals(String(plainTime), time);
});

Deno.test("can be localized", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
});

assertEquals(
plainTime.toLocaleString("en", { timeStyle: "medium" }),
"2:03:00 AM",
);
});
4 changes: 4 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export type { ComPlainDate, PlainDateFactory } from "./PlainDate.ts";
export { ExPlainDate } from "./ExPlainDate.ts";
export type { ExtendedPlainDate } from "./ExPlainDate.ts";

export { PlainTime } from "./PlainTime.ts";
export type { ComPlainTime, PlainTimeFactory } from "./PlainTime.ts";

export {
BUSINESS_DAYS_IN_WEEK,
DAYS_IN_COMMON_YEAR,
Expand Down Expand Up @@ -69,6 +72,7 @@ export { startOfYear } from "./utils/startOfYear.ts";
// Utils for making strings
export { datetimeLocal } from "./utils/datetimeLocal.ts";
export { formatPlainDate } from "./utils/formatPlainDate.ts";
export { formatPlainTime } from "./utils/formatPlainTime.ts";

// Utils for getting information about a date object
export { daysInMonth } from "./utils/daysInMonth.ts";
Expand Down
4 changes: 4 additions & 0 deletions mod_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ Deno.test("module exports PlainDate factory", () => {
Deno.test("module exports ExPlainDate factory", () => {
assertExists(mod.ExPlainDate);
});

Deno.test("module exports PlainTime factory", () => {
assertExists(mod.PlainTime);
});
8 changes: 2 additions & 6 deletions support/date-time-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ComPlainDate } from "../PlainDate.ts";
import { ComPlainTime } from "../PlainTime.ts";

/** Describes a date where parts can be numbers or strings. */
export type SloppyDate = {
Expand All @@ -21,10 +22,5 @@ export type SloppyDateTime = SloppyDate & SloppyTime;
/** Describes a tuple of separate plain-date and plain-time objects. */
export type SplitDateTime = [
ComPlainDate,
{
hour: number;
minute: number;
second: number;
millisecond: number;
},
ComPlainTime,
];
2 changes: 1 addition & 1 deletion utils/formatPlainDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ComPlainDate } from "../PlainDate.ts";

export type FormatPlainDateOptions = Omit<
Intl.DateTimeFormatOptions,
"timeZone"
"timeZone" | "timeZoneName"
>;

/**
Expand Down
16 changes: 16 additions & 0 deletions utils/formatPlainTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ComPlainTime, FormatPlainTimeOptions } from "../PlainTime.ts";

/**
* Get a function curried with a locale,
* to get another function curried with format options,
* from which to get localized strings of its plain-time arguments.
*
* @param locale An `Intl` locale string, will use system's locale if not given
* @returns A curried function that takes `Intl` format options and returns another curried function that operates on plain-times
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat | Intl.DateTimeFormat options on MDN}
*/
export function formatPlainTime(locale: Intl.LocalesArgument = undefined) {
return (options: FormatPlainTimeOptions = {}) =>
(time: ComPlainTime): string => time.toLocaleString(locale, options);
}
46 changes: 46 additions & 0 deletions utils/formatPlainTime_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { formatPlainTime } from "./formatPlainTime.ts";
import { PlainTime } from "../PlainTime.ts";
import { assert, assertEquals } from "../dev_deps.ts";

Deno.test("returns localized string for English locale", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
second: 4,
millisecond: 5,
});
assertEquals(formatPlainTime("en")()(plainTime), "2:03:04 AM");
});

Deno.test("returns localized string for Swedish locale", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
second: 4,
millisecond: 5,
});
assertEquals(formatPlainTime("sv")()(plainTime), "02:03:04");
});

Deno.test("returns localized string in default locale", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
second: 4,
millisecond: 5,
});
assert(formatPlainTime()()(plainTime));
});

Deno.test("takes Intl options", () => {
const plainTime = PlainTime({
hour: 2,
minute: 3,
second: 4,
millisecond: 5,
});
assertEquals(
formatPlainTime("en")({ hour: "numeric", hourCycle: "h23" })(plainTime),
"02",
);
});
Loading

0 comments on commit 806fbdf

Please sign in to comment.