From 8b060949784e2207a6bda69960f2c530bf975cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Sun, 5 Jul 2026 07:44:55 +0800 Subject: [PATCH] Deduplicate duration and number formatting internals Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RaRBJFZKSTA7naHGuQHfnQ --- src/module/abbreviateNumber.js | 28 +--- src/module/formatDuration.js | 71 +++++++++++ src/module/numberLabel.js | 22 +--- src/module/setChartData.js | 59 ++++----- src/module/timeLabel.js | 156 +++++++---------------- tests/unit/module/formatDuration.test.js | 61 +++++++++ 6 files changed, 207 insertions(+), 190 deletions(-) create mode 100644 src/module/formatDuration.js create mode 100644 tests/unit/module/formatDuration.test.js diff --git a/src/module/abbreviateNumber.js b/src/module/abbreviateNumber.js index e857175..fffbc2e 100644 --- a/src/module/abbreviateNumber.js +++ b/src/module/abbreviateNumber.js @@ -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: " " }); } diff --git a/src/module/formatDuration.js b/src/module/formatDuration.js new file mode 100644 index 0000000..09980f8 --- /dev/null +++ b/src/module/formatDuration.js @@ -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); +} diff --git a/src/module/numberLabel.js b/src/module/numberLabel.js index 089e1d0..2a7ca35 100644 --- a/src/module/numberLabel.js +++ b/src/module/numberLabel.js @@ -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("."); } diff --git a/src/module/setChartData.js b/src/module/setChartData.js index 872b85b..96a0748 100644 --- a/src/module/setChartData.js +++ b/src/module/setChartData.js @@ -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} 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")), + }); + }); } diff --git a/src/module/timeLabel.js b/src/module/timeLabel.js index de11aff..17b9364 100644 --- a/src/module/timeLabel.js +++ b/src/module/timeLabel.js @@ -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) { - return { - unitToUse: "d", - timeValue: secondToDecide / day, - }; - } 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, - }; - } + const unit = pickDurationUnit(secondToDecide, true); + return { + unitToUse: unit.symbol, + timeValue: secondToDecide / unit.seconds, + }; }; /** @@ -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; } /** diff --git a/tests/unit/module/formatDuration.test.js b/tests/unit/module/formatDuration.test.js new file mode 100644 index 0000000..206d875 --- /dev/null +++ b/tests/unit/module/formatDuration.test.js @@ -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"); + }); +});