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: seconds to duration #467

Merged
merged 8 commits into from
Nov 14, 2023
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
{
"name": "@dfinity/utils",
"path": "./packages/utils/dist/index.js",
"limit": "4 kB",
"limit": "5 kB",
"ignore": [
"@dfinity/agent",
"@dfinity/candid",
Expand Down
15 changes: 15 additions & 0 deletions packages/utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ npm i @dfinity/agent @dfinity/candid @dfinity/principal
- [encodeBase32](#gear-encodebase32)
- [decodeBase32](#gear-decodebase32)
- [bigEndianCrc32](#gear-bigendiancrc32)
- [secondsToDuration](#gear-secondstoduration)
- [debounce](#gear-debounce)
- [isNullish](#gear-isnullish)
- [nonNullish](#gear-nonnullish)
Expand Down Expand Up @@ -234,6 +235,20 @@ Parameters:

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/utils/crc.utils.ts#L61)

#### :gear: secondsToDuration

Convert seconds to a human-readable duration, such as "6 days, 10 hours."

| Function | Type |
| ------------------- | ------------------------------------------------------------------------------------ |
| `secondsToDuration` | `({ seconds, i18n, }: { seconds: bigint; i18n?: I18nSecondsToDuration; }) => string` |

Parameters:

- `options`: - The options object.

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/utils/date.utils.ts#L43)

#### :gear: debounce

| Function | Type |
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./utils/arrays.utils";
export * from "./utils/asserts.utils";
export * from "./utils/base32.utils";
export * from "./utils/crc.utils";
export * from "./utils/date.utils";
export * from "./utils/debounce.utils";
export * from "./utils/did.utils";
export * from "./utils/json.utils";
Expand Down
221 changes: 221 additions & 0 deletions packages/utils/src/utils/date.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { describe } from "@jest/globals";
import { I18nSecondsToDuration, secondsToDuration } from "./date.utils";

const EN_TIME = {
year: "year",
year_plural: "years",
month: "month",
month_plural: "months",
day: "day",
day_plural: "days",
hour: "hour",
hour_plural: "hours",
minute: "minute",
minute_plural: "minutes",
second: "second",
second_plural: "seconds",
};

const FR_TIME = {
year: "an",
year_plural: "ans",
month: "mois",
month_plural: "mois",
day: "jour",
day_plural: "jours",
hour: "heure",
hour_plural: "heures",
minute: "minute",
minute_plural: "minutes",
second: "seconde",
second_plural: "secondes",
};

const test = (
i18nResult: I18nSecondsToDuration,
i18n?: I18nSecondsToDuration,
) => {
// This function should not be smart. It should just make it easier to add
// numbers together to get the number of seconds we want to test.
const renderSeconds = ({
nonLeapYears = 0,
days = 0,
hours = 0,
minutes = 0,
seconds = 0,
}: {
nonLeapYears?: number;
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
}) => {
days += 365 * nonLeapYears;
hours += 24 * days;
minutes += 60 * hours;
seconds += 60 * minutes;
return secondsToDuration({ seconds: BigInt(seconds), i18n });
};

it("should give year details", () => {
expect(renderSeconds({ nonLeapYears: 1 })).toBe(`1 ${i18nResult.year}`);
expect(renderSeconds({ nonLeapYears: 1, seconds: 59 })).toBe(
`1 ${i18nResult.year}`,
);
expect(renderSeconds({ nonLeapYears: 1, minutes: 59 })).toBe(
`1 ${i18nResult.year}, 59 ${i18nResult.minute_plural}`,
);
expect(renderSeconds({ nonLeapYears: 1, hours: 23 })).toBe(
`1 ${i18nResult.year}, 23 ${i18nResult.hour_plural}`,
);
expect(renderSeconds({ nonLeapYears: 1, days: 1, seconds: -1 })).toBe(
`1 ${i18nResult.year}, 23 ${i18nResult.hour_plural}`,
);
expect(renderSeconds({ nonLeapYears: 1, days: 1 })).toBe(
`1 ${i18nResult.year}, 1 ${i18nResult.day}`,
);
expect(renderSeconds({ nonLeapYears: 1, days: 2 })).toBe(
`1 ${i18nResult.year}, 2 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 2, seconds: -1 })).toBe(
`1 ${i18nResult.year}, 364 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 2 })).toBe(
`2 ${i18nResult.year_plural}`,
);
expect(renderSeconds({ nonLeapYears: 2, minutes: 59 })).toBe(
`2 ${i18nResult.year_plural}, 59 ${i18nResult.minute_plural}`,
);
expect(renderSeconds({ nonLeapYears: 2, hours: 23 })).toBe(
`2 ${i18nResult.year_plural}, 23 ${i18nResult.hour_plural}`,
);
expect(renderSeconds({ nonLeapYears: 2, days: 1 })).toBe(
`2 ${i18nResult.year_plural}, 1 ${i18nResult.day}`,
);
expect(renderSeconds({ nonLeapYears: 2, days: 2 })).toBe(
`2 ${i18nResult.year_plural}, 2 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 3, seconds: -1 })).toBe(
`2 ${i18nResult.year_plural}, 364 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 3 })).toBe(
`3 ${i18nResult.year_plural}`,
);
// 4 actual years have a leap day so we add 1 day to 4 nonLeap years.
expect(renderSeconds({ nonLeapYears: 4, days: 1, seconds: -1 })).toBe(
`3 ${i18nResult.year_plural}, 365 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 4, days: 1 })).toBe(
`4 ${i18nResult.year_plural}`,
);
expect(renderSeconds({ nonLeapYears: 5, days: 1, seconds: -1 })).toBe(
`4 ${i18nResult.year_plural}, 364 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 5, days: 1 })).toBe(
`5 ${i18nResult.year_plural}`,
);
expect(renderSeconds({ nonLeapYears: 6, days: 1, seconds: -1 })).toBe(
`5 ${i18nResult.year_plural}, 364 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 6, days: 1 })).toBe(
`6 ${i18nResult.year_plural}`,
);
expect(renderSeconds({ nonLeapYears: 7, days: 1, seconds: -1 })).toBe(
`6 ${i18nResult.year_plural}, 364 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 7, days: 1 })).toBe(
`7 ${i18nResult.year_plural}`,
);
// 4 actual years have 2 leap days so we add 2 days to 8 nonLeap years.
expect(renderSeconds({ nonLeapYears: 8, days: 2, seconds: -1 })).toBe(
`7 ${i18nResult.year_plural}, 365 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 8, days: 2 })).toBe(
`8 ${i18nResult.year_plural}`,
);
expect(renderSeconds({ nonLeapYears: 9, days: 2, seconds: -1 })).toBe(
`8 ${i18nResult.year_plural}, 364 ${i18nResult.day_plural}`,
);
expect(renderSeconds({ nonLeapYears: 9, days: 2 })).toBe(
`9 ${i18nResult.year_plural}`,
);
});

it("should give day details", () => {
expect(renderSeconds({ days: 1 })).toBe(`1 ${i18nResult.day}`);
expect(renderSeconds({ days: 1, seconds: 59 })).toBe(`1 ${i18nResult.day}`);
expect(renderSeconds({ days: 1, minutes: 59 })).toBe(
`1 ${i18nResult.day}, 59 ${i18nResult.minute_plural}`,
);
expect(renderSeconds({ days: 1, hours: 1 })).toBe(
`1 ${i18nResult.day}, 1 ${i18nResult.hour}`,
);
expect(renderSeconds({ days: 1, hours: 2 })).toBe(
`1 ${i18nResult.day}, 2 ${i18nResult.hour_plural}`,
);
expect(renderSeconds({ days: 2, seconds: -1 })).toBe(
`1 ${i18nResult.day}, 23 ${i18nResult.hour_plural}`,
);
expect(renderSeconds({ days: 2 })).toBe(`2 ${i18nResult.day_plural}`);
expect(renderSeconds({ days: 365, seconds: -1 })).toBe(
`364 ${i18nResult.day_plural}, 23 ${i18nResult.hour_plural}`,
);
});

it("should give hour details", () => {
expect(renderSeconds({ hours: 1 })).toBe(`1 ${i18nResult.hour}`);
expect(renderSeconds({ hours: 1, seconds: 59 })).toBe(
`1 ${i18nResult.hour}`,
);
expect(renderSeconds({ hours: 1, minutes: 59 })).toBe(
`1 ${i18nResult.hour}, 59 ${i18nResult.minute_plural}`,
);
expect(renderSeconds({ hours: 2, seconds: -1 })).toBe(
`1 ${i18nResult.hour}, 59 ${i18nResult.minute_plural}`,
);
expect(renderSeconds({ hours: 2 })).toBe(`2 ${i18nResult.hour_plural}`);
expect(renderSeconds({ hours: 2, minutes: 59 })).toBe(
`2 ${i18nResult.hour_plural}, 59 ${i18nResult.minute_plural}`,
);
expect(renderSeconds({ hours: 24, seconds: -1 })).toBe(
`23 ${i18nResult.hour_plural}, 59 ${i18nResult.minute_plural}`,
);
});

it("should give minute details", () => {
expect(renderSeconds({ minutes: 1 })).toBe(`1 ${i18nResult.minute}`);
expect(renderSeconds({ minutes: 1, seconds: 1 })).toBe(
`1 ${i18nResult.minute}`,
);
expect(renderSeconds({ minutes: 1, seconds: 59 })).toBe(
`1 ${i18nResult.minute}`,
);
expect(renderSeconds({ minutes: 2 })).toBe(`2 ${i18nResult.minute_plural}`);
expect(renderSeconds({ minutes: 2, seconds: 59 })).toBe(
`2 ${i18nResult.minute_plural}`,
);
expect(renderSeconds({ minutes: 60, seconds: -1 })).toBe(
`59 ${i18nResult.minute_plural}`,
);
});

it("should give seconds details", () => {
expect(secondsToDuration({ seconds: BigInt(2), i18n })).toBe(
`2 ${i18nResult.second_plural}`,
);
expect(secondsToDuration({ seconds: BigInt(59), i18n })).toBe(
`59 ${i18nResult.second_plural}`,
);
});

it("should give a second details", () => {
expect(secondsToDuration({ seconds: BigInt(1), i18n })).toBe(
`1 ${i18nResult.second}`,
);
});
};

describe("secondsToDuration default lang", () => test(EN_TIME, undefined));
describe.each([EN_TIME, FR_TIME])("secondsToDuration %p", (time) =>
test(time, time),
);
108 changes: 108 additions & 0 deletions packages/utils/src/utils/date.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const SECONDS_IN_MINUTE = 60;
const MINUTES_IN_HOUR = 60;
const HOURS_IN_DAY = 24;
const DAYS_IN_NON_LEAP_YEAR = 365;

export interface I18nSecondsToDuration {
year: string;
year_plural: string;
month: string;
month_plural: string;
day: string;
day_plural: string;
hour: string;
hour_plural: string;
minute: string;
minute_plural: string;
second: string;
second_plural: string;
}

const EN_TIME: I18nSecondsToDuration = {
year: "year",
year_plural: "years",
month: "month",
month_plural: "months",
day: "day",
day_plural: "days",
hour: "hour",
hour_plural: "hours",
minute: "minute",
minute_plural: "minutes",
second: "second",
second_plural: "seconds",
};

/**
* Convert seconds to a human-readable duration, such as "6 days, 10 hours."
* @param {Object} options - The options object.
* @param {bigint} options.seconds - The number of seconds to convert.
* @param {I18nSecondsToDuration} [options.i18n] - The i18n object for customizing language and units. Defaults to English.
* @returns {string} The human-readable duration string.
*/
export const secondsToDuration = ({
seconds,
i18n = EN_TIME,
}: {
seconds: bigint;
i18n?: I18nSecondsToDuration;
}): string => {
let minutes = seconds / BigInt(SECONDS_IN_MINUTE);

let hours = minutes / BigInt(MINUTES_IN_HOUR);
minutes -= hours * BigInt(MINUTES_IN_HOUR);

let days = hours / BigInt(HOURS_IN_DAY);
hours -= days * BigInt(HOURS_IN_DAY);

const years = fullYearsInDays(days);
days -= daysInYears(years);

const periods = [
createLabel("year", years),
createLabel("day", days),
createLabel("hour", hours),
createLabel("minute", minutes),
...(seconds > BigInt(0) && seconds < BigInt(60)
? [createLabel("second", seconds)]
: []),
];

return periods
.filter(({ amount }) => amount > 0)
.slice(0, 2)
.map(
(labelInfo) =>
`${labelInfo.amount} ${
labelInfo.amount === 1
? i18n[labelInfo.labelKey]
: i18n[`${labelInfo.labelKey}_plural`]
}`,
)
.join(", ");
};

const fullYearsInDays = (days: bigint): bigint => {
// Use integer division.
let years = days / BigInt(DAYS_IN_NON_LEAP_YEAR);
while (daysInYears(years) > days) {
years--;
}
return years;
};

const daysInYears = (years: bigint): bigint => {
// Use integer division.
const leapDays = years / BigInt(4);
return years * BigInt(DAYS_IN_NON_LEAP_YEAR) + leapDays;
};

type LabelKey = "year" | "month" | "day" | "hour" | "minute" | "second";
type LabelInfo = {
labelKey: LabelKey;
amount: number;
};
const createLabel = (labelKey: LabelKey, amount: bigint): LabelInfo => ({
labelKey,
amount: Number(amount),
});
Loading