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:
2026-07-05 07:44:55 +08:00
co-authored by Claude Fable 5
parent ab1eacc1c2
commit 8b06094978
6 changed files with 207 additions and 190 deletions
+5 -23
View File
@@ -6,6 +6,8 @@
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30 // cindy.chang@dsp.im (Cindy Chang), 2024/5/30
/** @module abbreviateNumber Duration abbreviation formatting. */ /** @module abbreviateNumber Duration abbreviation formatting. */
import formatDuration from "./formatDuration.js";
/** /**
* Converts a total number of seconds into a human-readable abbreviated * Converts a total number of seconds into a human-readable abbreviated
* duration string using days, hours, minutes, and seconds. * duration string using days, hours, minutes, and seconds.
@@ -15,27 +17,7 @@
* or "0" if totalSeconds is zero. * or "0" if totalSeconds is zero.
*/ */
export default function abbreviateNumber(totalSeconds) { export default function abbreviateNumber(totalSeconds) {
let seconds = 0; const parsed = Number.parseInt(totalSeconds);
let minutes = 0; if (parsed === 0) return "0";
let hours = 0; return formatDuration(parsed, { separator: " " });
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;
} }
+71
View File
@@ -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);
}
+5 -17
View File
@@ -6,30 +6,18 @@
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30 // cindy.chang@dsp.im (Cindy Chang), 2024/5/30
/** @module numberLabel Number formatting with comma separators. */ /** @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. * 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. * @param {number} num - The number to format.
* @returns {string} The formatted number string (e.g. "1,234.56"). * @returns {string} The formatted number string (e.g. "1,234.56").
*/ */
export default function numberLabel(num) { export default function numberLabel(num) {
let parts = num.toString().split("."); const parts = num.toString().split(".");
parts[0] = formatNumberWithCommas(parts[0]); parts[0] = parts[0].replace(/(.)(?=(?:.{3})+$)/g, "$1,");
return parts.join("."); return parts.join(".");
} }
+22 -37
View File
@@ -8,6 +8,8 @@
import getMoment from "moment"; import getMoment from "moment";
import formatDuration, { DURATION_UNITS } from "./formatDuration.js";
/** /**
* Extends backend chart data with extrapolated boundary points for * Extends backend chart data with extrapolated boundary points for
* line charts. Prepends a calculated minimum and appends a calculated * line charts. Prepends a calculated minimum and appends a calculated
@@ -241,26 +243,8 @@ export function getXIndex(data, xValue) {
export function formatTime(seconds) { export function formatTime(seconds) {
if (Number.isNaN(Number(seconds))) { if (Number.isNaN(Number(seconds))) {
return null; 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. * 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"). * @returns {Array<string>} Array of truncated strings (e.g. "2d 3h").
*/ */
export function formatMaxTwo(times) { export function formatMaxTwo(times) {
const formattedTimes = []; const secondsPerUnit = Object.fromEntries(
for (let time of times) { DURATION_UNITS.map((unit) => [unit.symbol, unit.seconds]),
// Match numbers and units (days, hours, minutes, seconds); assume numbers have at most 10 digits );
let units = time.match(/\d{1,10}[dhms]/g) || []; return times.map((time) => {
let formattedTime = ""; // Match numbers and units (days, hours, minutes, seconds); assume
let count = 0; // numbers have at most 10 digits.
const units = time.match(/\d{1,10}[dhms]/g);
// Keep only the two largest units if (!units) return "";
for (let unit of units) { const totalSeconds = units.reduce(
if (count >= 2) { (sum, unit) =>
break; sum + Number.parseInt(unit) * secondsPerUnit[unit.slice(-1)],
} 0,
formattedTime += unit + " "; );
count++; return formatDuration(totalSeconds, {
} separator: " ",
formattedTimes.push(formattedTime.trim()); // Remove trailing whitespace maxUnits: 2,
} alwaysSeconds: units.some((unit) => unit.endsWith("s")),
return formattedTimes; });
});
} }
+41 -111
View File
@@ -8,6 +8,8 @@
import moment from "moment"; import moment from "moment";
import { pickDurationUnit } from "./formatDuration.js";
/** @constant {number} Number of decimal places for formatted time values. */ /** @constant {number} Number of decimal places for formatted time values. */
const TOFIXED_DECIMAL = 1; const TOFIXED_DECIMAL = 1;
@@ -36,34 +38,11 @@ export const getStepSizeOfYTicks = (maxTimeInSecond, numOfParts) => {
* character ("d", "h", "m", or "s") and the converted time value. * character ("d", "h", "m", or "s") and the converted time value.
*/ */
const getTimeUnitAndValueToUse = (secondToDecide) => { const getTimeUnitAndValueToUse = (secondToDecide) => {
const day = 24 * 60 * 60; const unit = pickDurationUnit(secondToDecide, true);
const hour = 60 * 60;
const minutes = 60;
const dd = secondToDecide / day;
const hh = secondToDecide / hour;
const mm = secondToDecide / minutes;
if (dd > 0 && dd > 1) {
return { return {
unitToUse: "d", unitToUse: unit.symbol,
timeValue: secondToDecide / day, 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}`; 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. * 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"). * "30 mins", "5 sec").
*/ */
export function getTimeLabel(second, fixedNumber = 0) { export function getTimeLabel(second, fixedNumber = 0) {
const day = 24 * 60 * 60; return scaledTimeLabel(second, fixedNumber, {
const hour = 60 * 60; d: " days",
const minutes = 60; h: " hrs",
m: " mins",
// The modulo operation limits the value to within one day (0 to 86399), s: " sec",
// 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";
} }
/** /**
@@ -123,24 +108,12 @@ export function getTimeLabel(second, fixedNumber = 0) {
* @returns {string} The formatted string (e.g. "1d", "6.8h", "30m", "5s"). * @returns {string} The formatted string (e.g. "1d", "6.8h", "30m", "5s").
*/ */
export function simpleTimeLabel(second, fixedNumber = 0) { export function simpleTimeLabel(second, fixedNumber = 0) {
const day = 24 * 60 * 60; return scaledTimeLabel(second, fixedNumber, {
const hour = 60 * 60; d: "d",
const minutes = 60; h: "h",
const dd = Math.floor(second / day); m: "m",
const hh = Math.floor((second % day) / hour); s: "s",
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";
} }
/** /**
* Converts seconds to a time string using the same unit as the maximum * 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"). * @returns {string} The formatted string (e.g. "1.5d", "6.8h").
*/ */
export function followTimeLabel(second, max, fixedNumber = 0) { export function followTimeLabel(second, max, fixedNumber = 0) {
const day = 24 * 60 * 60; const unit = pickDurationUnit(max, true);
const hour = 60 * 60; const value = second / unit.seconds;
const minutes = 60; return value.toFixed(value === 0 ? 0 : fixedNumber) + unit.symbol;
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;
} }
/** /**
+61
View File
@@ -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");
});
});