Deduplicate duration and number formatting internals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RaRBJFZKSTA7naHGuQHfnQ
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
|
||||
/** @module abbreviateNumber Duration abbreviation formatting. */
|
||||
|
||||
import formatDuration from "./formatDuration.js";
|
||||
|
||||
/**
|
||||
* Converts a total number of seconds into a human-readable abbreviated
|
||||
* duration string using days, hours, minutes, and seconds.
|
||||
@@ -15,27 +17,7 @@
|
||||
* or "0" if totalSeconds is zero.
|
||||
*/
|
||||
export default function abbreviateNumber(totalSeconds) {
|
||||
let seconds = 0;
|
||||
let minutes = 0;
|
||||
let hours = 0;
|
||||
let days = 0;
|
||||
let result = "";
|
||||
let symbols = ["d", "h", "m", "s"];
|
||||
|
||||
totalSeconds = Number.parseInt(totalSeconds);
|
||||
if (!Number.isNaN(totalSeconds)) {
|
||||
seconds = totalSeconds % 60;
|
||||
minutes = (Math.floor(totalSeconds - seconds) / 60) % 60;
|
||||
hours = Math.floor(totalSeconds / 3600) % 24;
|
||||
days = Math.floor(totalSeconds / (3600 * 24));
|
||||
}
|
||||
|
||||
const units = [days, hours, minutes, seconds];
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
if (units[i] > 0) result += units[i] + symbols[i] + " ";
|
||||
}
|
||||
result = result.trim();
|
||||
if (totalSeconds === 0) result = "0";
|
||||
|
||||
return result;
|
||||
const parsed = Number.parseInt(totalSeconds);
|
||||
if (parsed === 0) return "0";
|
||||
return formatDuration(parsed, { separator: " " });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// The Lucia project.
|
||||
// Copyright 2026 DSP, inc. All rights reserved.
|
||||
// Authors:
|
||||
// imacat.yang@dsp.im (imacat), 2026/7/5
|
||||
// AI assistance: Claude Code (Anthropic)
|
||||
/** @module formatDuration Shared duration formatting internals. */
|
||||
|
||||
/**
|
||||
* The duration units from the largest to the smallest.
|
||||
*
|
||||
* @constant {Array<{symbol: string, seconds: number}>}
|
||||
*/
|
||||
export const DURATION_UNITS = [
|
||||
{ symbol: "d", seconds: 24 * 60 * 60 },
|
||||
{ symbol: "h", seconds: 60 * 60 },
|
||||
{ symbol: "m", seconds: 60 },
|
||||
{ symbol: "s", seconds: 1 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Picks the largest duration unit suitable for a number of seconds.
|
||||
*
|
||||
* @param {number} seconds - The number of seconds to pick a unit for.
|
||||
* @param {boolean} [exclusive=false] - Whether a unit is chosen only
|
||||
* when the value is strictly greater than one unit (true), or when
|
||||
* it is greater than or equal to one unit (false).
|
||||
* @returns {{symbol: string, seconds: number}} The chosen unit; falls
|
||||
* back to seconds when no larger unit qualifies.
|
||||
*/
|
||||
export function pickDurationUnit(seconds, exclusive = false) {
|
||||
return DURATION_UNITS.find(
|
||||
(unit) =>
|
||||
unit.seconds === 1 ||
|
||||
(exclusive ? seconds > unit.seconds : seconds >= unit.seconds),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds as a day/hour/minute/second cascade.
|
||||
*
|
||||
* Zero-valued units are omitted, except that the seconds unit can be
|
||||
* forced with the alwaysSeconds option. The result is truncated to at
|
||||
* most maxUnits units, starting from the largest.
|
||||
*
|
||||
* @param {number} totalSeconds - The total number of seconds.
|
||||
* @param {Object} [options] - The formatting options.
|
||||
* @param {string} [options.separator=" "] - The string between units.
|
||||
* @param {number} [options.maxUnits=Infinity] - The maximum number of
|
||||
* units to keep, starting from the largest.
|
||||
* @param {boolean} [options.alwaysSeconds=false] - Whether to always
|
||||
* append the seconds unit, even when it is zero.
|
||||
* @returns {string} The formatted duration (e.g. "2d 3h 15m 30s"), or
|
||||
* an empty string when no unit qualifies.
|
||||
*/
|
||||
export default function formatDuration(
|
||||
totalSeconds,
|
||||
{ separator = " ", maxUnits = Infinity, alwaysSeconds = false } = {},
|
||||
) {
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = (Math.floor(totalSeconds - seconds) / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600) % 24;
|
||||
const days = Math.floor(totalSeconds / (3600 * 24));
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(days + "d");
|
||||
if (hours > 0) parts.push(hours + "h");
|
||||
if (minutes > 0) parts.push(minutes + "m");
|
||||
if (seconds > 0 || alwaysSeconds) parts.push(seconds + "s");
|
||||
|
||||
return parts.slice(0, maxUnits).join(separator);
|
||||
}
|
||||
@@ -6,30 +6,18 @@
|
||||
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
|
||||
/** @module numberLabel Number formatting with comma separators. */
|
||||
|
||||
/**
|
||||
* Formats an integer string by inserting commas every three digits.
|
||||
* @param {string} numberStr - A string of digits to format.
|
||||
* @returns {string} The formatted string with commas (e.g. "1,000,000").
|
||||
*/
|
||||
const formatNumberWithCommas = (numberStr) => {
|
||||
let reversedStr = numberStr.split("").reverse().join("");
|
||||
let groupedStr = reversedStr.match(/.{1,3}/g) || [];
|
||||
let joinedStr = groupedStr.join(",");
|
||||
let finalStr = joinedStr.split("").reverse().join("");
|
||||
|
||||
return finalStr;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a number to a string with comma-separated thousands.
|
||||
*
|
||||
* Handles decimal numbers by formatting only the integer part.
|
||||
* Handles decimal numbers by formatting only the integer part. A comma
|
||||
* is inserted before every group of three characters counted from the
|
||||
* end of the integer part.
|
||||
*
|
||||
* @param {number} num - The number to format.
|
||||
* @returns {string} The formatted number string (e.g. "1,234.56").
|
||||
*/
|
||||
export default function numberLabel(num) {
|
||||
let parts = num.toString().split(".");
|
||||
parts[0] = formatNumberWithCommas(parts[0]);
|
||||
const parts = num.toString().split(".");
|
||||
parts[0] = parts[0].replace(/(.)(?=(?:.{3})+$)/g, "$1,");
|
||||
return parts.join(".");
|
||||
}
|
||||
|
||||
+22
-37
@@ -8,6 +8,8 @@
|
||||
|
||||
import getMoment from "moment";
|
||||
|
||||
import formatDuration, { DURATION_UNITS } from "./formatDuration.js";
|
||||
|
||||
/**
|
||||
* Extends backend chart data with extrapolated boundary points for
|
||||
* line charts. Prepends a calculated minimum and appends a calculated
|
||||
@@ -241,26 +243,8 @@ export function getXIndex(data, xValue) {
|
||||
export function formatTime(seconds) {
|
||||
if (Number.isNaN(Number(seconds))) {
|
||||
return null;
|
||||
} else {
|
||||
const remainingSeconds = seconds % 60;
|
||||
const minutes = (Math.floor(seconds - remainingSeconds) / 60) % 60;
|
||||
const hours = Math.floor(seconds / 3600) % 24;
|
||||
const days = Math.floor(seconds / (3600 * 24));
|
||||
|
||||
let result = "";
|
||||
if (days > 0) {
|
||||
result += `${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
result += `${hours}h`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
result += `${minutes}m`;
|
||||
}
|
||||
result += `${remainingSeconds}s`;
|
||||
|
||||
return result.trim(); // Remove trailing whitespace
|
||||
}
|
||||
return formatDuration(seconds, { separator: "", alwaysSeconds: true });
|
||||
}
|
||||
/**
|
||||
* Truncates each time string to show only the two largest time units.
|
||||
@@ -269,22 +253,23 @@ export function formatTime(seconds) {
|
||||
* @returns {Array<string>} Array of truncated strings (e.g. "2d 3h").
|
||||
*/
|
||||
export function formatMaxTwo(times) {
|
||||
const formattedTimes = [];
|
||||
for (let time of times) {
|
||||
// Match numbers and units (days, hours, minutes, seconds); assume numbers have at most 10 digits
|
||||
let units = time.match(/\d{1,10}[dhms]/g) || [];
|
||||
let formattedTime = "";
|
||||
let count = 0;
|
||||
|
||||
// Keep only the two largest units
|
||||
for (let unit of units) {
|
||||
if (count >= 2) {
|
||||
break;
|
||||
}
|
||||
formattedTime += unit + " ";
|
||||
count++;
|
||||
}
|
||||
formattedTimes.push(formattedTime.trim()); // Remove trailing whitespace
|
||||
}
|
||||
return formattedTimes;
|
||||
const secondsPerUnit = Object.fromEntries(
|
||||
DURATION_UNITS.map((unit) => [unit.symbol, unit.seconds]),
|
||||
);
|
||||
return times.map((time) => {
|
||||
// Match numbers and units (days, hours, minutes, seconds); assume
|
||||
// numbers have at most 10 digits.
|
||||
const units = time.match(/\d{1,10}[dhms]/g);
|
||||
if (!units) return "";
|
||||
const totalSeconds = units.reduce(
|
||||
(sum, unit) =>
|
||||
sum + Number.parseInt(unit) * secondsPerUnit[unit.slice(-1)],
|
||||
0,
|
||||
);
|
||||
return formatDuration(totalSeconds, {
|
||||
separator: " ",
|
||||
maxUnits: 2,
|
||||
alwaysSeconds: units.some((unit) => unit.endsWith("s")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+41
-111
@@ -8,6 +8,8 @@
|
||||
|
||||
import moment from "moment";
|
||||
|
||||
import { pickDurationUnit } from "./formatDuration.js";
|
||||
|
||||
/** @constant {number} Number of decimal places for formatted time values. */
|
||||
const TOFIXED_DECIMAL = 1;
|
||||
|
||||
@@ -36,34 +38,11 @@ export const getStepSizeOfYTicks = (maxTimeInSecond, numOfParts) => {
|
||||
* character ("d", "h", "m", or "s") and the converted time value.
|
||||
*/
|
||||
const getTimeUnitAndValueToUse = (secondToDecide) => {
|
||||
const day = 24 * 60 * 60;
|
||||
const hour = 60 * 60;
|
||||
const minutes = 60;
|
||||
const dd = secondToDecide / day;
|
||||
const hh = secondToDecide / hour;
|
||||
const mm = secondToDecide / minutes;
|
||||
|
||||
if (dd > 0 && dd > 1) {
|
||||
const unit = pickDurationUnit(secondToDecide, true);
|
||||
return {
|
||||
unitToUse: "d",
|
||||
timeValue: secondToDecide / day,
|
||||
unitToUse: unit.symbol,
|
||||
timeValue: secondToDecide / unit.seconds,
|
||||
};
|
||||
} else if (hh > 0 && hh > 1) {
|
||||
return {
|
||||
unitToUse: "h",
|
||||
timeValue: secondToDecide / hour,
|
||||
};
|
||||
} else if (mm > 0 && mm > 1) {
|
||||
return {
|
||||
unitToUse: "m",
|
||||
timeValue: secondToDecide / minutes,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
unitToUse: "s",
|
||||
timeValue: secondToDecide,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,6 +59,27 @@ export function getYTicksByIndex(stepSize, index, unitToUse) {
|
||||
return `${shortenStepsizeMultIndex}${unitToUse}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats seconds as a single-unit time label with the given suffixes.
|
||||
*
|
||||
* Chooses the largest unit whose size the value reaches. Values are
|
||||
* rounded to fixedNumber decimal places, except seconds, which are
|
||||
* concatenated as-is.
|
||||
*
|
||||
* @param {number} second - The total number of seconds.
|
||||
* @param {number} fixedNumber - Number of decimal places.
|
||||
* @param {{d: string, h: string, m: string, s: string}} suffixes - The
|
||||
* unit suffixes to append.
|
||||
* @returns {string} The formatted time label.
|
||||
*/
|
||||
function scaledTimeLabel(second, fixedNumber, suffixes) {
|
||||
const unit = pickDurationUnit(second);
|
||||
if (unit.symbol === "s") {
|
||||
return second + suffixes.s;
|
||||
}
|
||||
return (second / unit.seconds).toFixed(fixedNumber) + suffixes[unit.symbol];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts seconds to a human-readable time string with full unit names.
|
||||
*
|
||||
@@ -89,27 +89,12 @@ export function getYTicksByIndex(stepSize, index, unitToUse) {
|
||||
* "30 mins", "5 sec").
|
||||
*/
|
||||
export function getTimeLabel(second, fixedNumber = 0) {
|
||||
const day = 24 * 60 * 60;
|
||||
const hour = 60 * 60;
|
||||
const minutes = 60;
|
||||
|
||||
// The modulo operation limits the value to within one day (0 to 86399),
|
||||
// representing the remaining seconds after full days.
|
||||
const dd = Math.floor(second / day);
|
||||
const hh = Math.floor((second % day) / hour);
|
||||
const mm = Math.floor((second % hour) / minutes);
|
||||
|
||||
if (dd > 0) {
|
||||
return (second / day).toFixed(fixedNumber) + " days";
|
||||
} else if (hh > 0) {
|
||||
return ((second % day) / hour).toFixed(fixedNumber) + " hrs";
|
||||
} else if (mm > 0) {
|
||||
return ((second % hour) / minutes).toFixed(fixedNumber) + " mins";
|
||||
}
|
||||
if (second === 0) {
|
||||
return second + " sec";
|
||||
}
|
||||
return second + " sec";
|
||||
return scaledTimeLabel(second, fixedNumber, {
|
||||
d: " days",
|
||||
h: " hrs",
|
||||
m: " mins",
|
||||
s: " sec",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,24 +108,12 @@ export function getTimeLabel(second, fixedNumber = 0) {
|
||||
* @returns {string} The formatted string (e.g. "1d", "6.8h", "30m", "5s").
|
||||
*/
|
||||
export function simpleTimeLabel(second, fixedNumber = 0) {
|
||||
const day = 24 * 60 * 60;
|
||||
const hour = 60 * 60;
|
||||
const minutes = 60;
|
||||
const dd = Math.floor(second / day);
|
||||
const hh = Math.floor((second % day) / hour);
|
||||
const mm = Math.floor((second % hour) / minutes);
|
||||
|
||||
if (dd > 0) {
|
||||
return (second / day).toFixed(fixedNumber) + "d";
|
||||
} else if (hh > 0) {
|
||||
return ((second % day) / hour).toFixed(fixedNumber) + "h";
|
||||
} else if (mm > 0) {
|
||||
return ((second % hour) / minutes).toFixed(fixedNumber) + "m";
|
||||
}
|
||||
if (second === 0) {
|
||||
return second + "s";
|
||||
}
|
||||
return second + "s";
|
||||
return scaledTimeLabel(second, fixedNumber, {
|
||||
d: "d",
|
||||
h: "h",
|
||||
m: "m",
|
||||
s: "s",
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Converts seconds to a time string using the same unit as the maximum
|
||||
@@ -155,52 +128,9 @@ export function simpleTimeLabel(second, fixedNumber = 0) {
|
||||
* @returns {string} The formatted string (e.g. "1.5d", "6.8h").
|
||||
*/
|
||||
export function followTimeLabel(second, max, fixedNumber = 0) {
|
||||
const day = 24 * 60 * 60;
|
||||
const hour = 60 * 60;
|
||||
const minutes = 60;
|
||||
|
||||
const dd = max / day;
|
||||
const hh = max / hour;
|
||||
const mm = max / minutes;
|
||||
let maxUnit = "";
|
||||
let result = "";
|
||||
|
||||
if (dd > 1) {
|
||||
maxUnit = "d";
|
||||
} else if (hh > 1) {
|
||||
maxUnit = "h";
|
||||
} else if (mm > 1) {
|
||||
maxUnit = "m";
|
||||
} else {
|
||||
maxUnit = "s";
|
||||
}
|
||||
switch (maxUnit) {
|
||||
case "d":
|
||||
if (second / day === 0) {
|
||||
fixedNumber = 0;
|
||||
}
|
||||
result = (second / day).toFixed(fixedNumber) + "d";
|
||||
break;
|
||||
case "h":
|
||||
if (second / hour === 0) {
|
||||
fixedNumber = 0;
|
||||
}
|
||||
result = (second / hour).toFixed(fixedNumber) + "h";
|
||||
break;
|
||||
case "m":
|
||||
if (second / minutes === 0) {
|
||||
fixedNumber = 0;
|
||||
}
|
||||
result = (second / minutes).toFixed(fixedNumber) + "m";
|
||||
break;
|
||||
case "s":
|
||||
if (second === 0) {
|
||||
fixedNumber = 0;
|
||||
}
|
||||
result = second.toFixed(fixedNumber) + "s";
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
const unit = pickDurationUnit(max, true);
|
||||
const value = second / unit.seconds;
|
||||
return value.toFixed(value === 0 ? 0 : fixedNumber) + unit.symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// The Lucia project.
|
||||
// Copyright 2026-2026 DSP, inc. All rights reserved.
|
||||
// Authors:
|
||||
// imacat.yang@dsp.im (imacat), 2026/7/5
|
||||
// AI assistance: Claude Code (Anthropic)
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import formatDuration, { pickDurationUnit } from "@/module/formatDuration.js";
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats a full cascade with the default separator", () => {
|
||||
expect(formatDuration(93784)).toBe("1d 2h 3m 4s");
|
||||
});
|
||||
|
||||
it("omits zero-valued units", () => {
|
||||
expect(formatDuration(86400)).toBe("1d");
|
||||
expect(formatDuration(3661)).toBe("1h 1m 1s");
|
||||
});
|
||||
|
||||
it("returns an empty string when no unit qualifies", () => {
|
||||
expect(formatDuration(0)).toBe("");
|
||||
expect(formatDuration(NaN)).toBe("");
|
||||
});
|
||||
|
||||
it("supports a custom separator", () => {
|
||||
expect(formatDuration(90061, { separator: "" })).toBe("1d1h1m1s");
|
||||
});
|
||||
|
||||
it("always appends seconds when alwaysSeconds is set", () => {
|
||||
expect(formatDuration(0, { alwaysSeconds: true })).toBe("0s");
|
||||
expect(formatDuration(60, { separator: "", alwaysSeconds: true })).toBe(
|
||||
"1m0s",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates to maxUnits largest units", () => {
|
||||
expect(formatDuration(93784, { maxUnits: 2 })).toBe("1d 2h");
|
||||
expect(formatDuration(45, { maxUnits: 2 })).toBe("45s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDurationUnit", () => {
|
||||
it("picks the largest unit reached by the value", () => {
|
||||
expect(pickDurationUnit(86400).symbol).toBe("d");
|
||||
expect(pickDurationUnit(3600).symbol).toBe("h");
|
||||
expect(pickDurationUnit(60).symbol).toBe("m");
|
||||
expect(pickDurationUnit(59).symbol).toBe("s");
|
||||
});
|
||||
|
||||
it("requires strictly more than one unit when exclusive", () => {
|
||||
expect(pickDurationUnit(86400, true).symbol).toBe("h");
|
||||
expect(pickDurationUnit(86401, true).symbol).toBe("d");
|
||||
expect(pickDurationUnit(60, true).symbol).toBe("s");
|
||||
expect(pickDurationUnit(61, true).symbol).toBe("m");
|
||||
});
|
||||
|
||||
it("falls back to seconds for zero and negative values", () => {
|
||||
expect(pickDurationUnit(0).symbol).toBe("s");
|
||||
expect(pickDurationUnit(-100).symbol).toBe("s");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user