diff --git a/packages/client/src/object/formatting/applyPropertyFormatter.test.ts b/packages/client/src/object/formatting/applyPropertyFormatter.test.ts index 38808ac1a4..f266144944 100644 --- a/packages/client/src/object/formatting/applyPropertyFormatter.test.ts +++ b/packages/client/src/object/formatting/applyPropertyFormatter.test.ts @@ -402,6 +402,114 @@ describe("getFormattedValue", () => { nullable: true, multiplicity: false, }, + durationSeconds: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "SECONDS", + formatStyle: { type: "humanReadable" }, + }, + }, + }, + durationSecondsFullUnits: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "SECONDS", + formatStyle: { type: "humanReadable", showFullUnits: true }, + }, + }, + }, + durationTimecode: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "SECONDS", + formatStyle: { type: "timecode" }, + }, + }, + }, + durationPrecisionDays: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "SECONDS", + formatStyle: { type: "humanReadable" }, + precision: "DAYS", + }, + }, + }, + durationPrecisionHours: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "SECONDS", + formatStyle: { type: "humanReadable" }, + precision: "HOURS", + }, + }, + }, + durationPrecisionMinutes: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "SECONDS", + formatStyle: { type: "humanReadable" }, + precision: "MINUTES", + }, + }, + }, + durationPrecisionSeconds: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "SECONDS", + formatStyle: { type: "humanReadable" }, + precision: "SECONDS", + }, + }, + }, + durationMilliseconds: { + type: "double", + nullable: false, + multiplicity: false, + valueFormatting: { + type: "number", + numberType: { + type: "duration", + baseValue: "MILLISECONDS", + formatStyle: { type: "humanReadable" }, + }, + }, + }, }, }; @@ -437,6 +545,14 @@ describe("getFormattedValue", () => { timestampWithUserTimezone: "2025-01-15T14:30:00.000Z", timestampWithDynamicTimezone: "2025-01-15T14:30:00.000Z", timezoneId: "Europe/London", + durationSeconds: 0, + durationSecondsFullUnits: 0, + durationTimecode: 0, + durationPrecisionDays: 0, + durationPrecisionHours: 0, + durationPrecisionMinutes: 0, + durationPrecisionSeconds: 0, + durationMilliseconds: 0, }; // Helper to create an OSDK object with optional data overrides @@ -779,4 +895,599 @@ describe("getFormattedValue", () => { expect(formatted).toBeUndefined(); }); }); + + describe("Duration formatting", () => { + describe("Human-readable format (compact)", () => { + it("formats 0 seconds", () => { + const obj = getObject({ durationSeconds: 0 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("0s"); + }); + + it("formats 10 seconds", () => { + const obj = getObject({ durationSeconds: 10 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("10s"); + }); + + it("formats 60 seconds as a minute", () => { + const obj = getObject({ durationSeconds: 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("1m"); + }); + + it("formats 60 minutes as an hour", () => { + const obj = getObject({ durationSeconds: 60 * 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("1h"); + }); + + it("formats 24 hours as a day", () => { + const obj = getObject({ durationSeconds: 60 * 60 * 24 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("1d"); + }); + + it("formats 2 days and 1 second", () => { + const obj = getObject({ durationSeconds: 60 * 60 * 48 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("2d 1s"); + }); + + it("formats 2 days and 1 hour", () => { + const obj = getObject({ durationSeconds: 60 * 60 * 48 + 60 * 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("2d 1h"); + }); + + it("formats 2 days and 1 min", () => { + const obj = getObject({ durationSeconds: 60 * 60 * 48 + 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("2d 1m"); + }); + + it("formats a day, 2 hours, 1 minute and 1 second", () => { + const obj = getObject({ + durationSeconds: 60 * 60 * 24 + 60 * 60 * 2 + 60 + 1, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("1d 2h 1m 1s"); + }); + + it("formats 2 hours, 1 minute and 1 second", () => { + const obj = getObject({ durationSeconds: 60 * 60 * 2 + 60 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("2h 1m 1s"); + }); + + it("formats 2 hours and 1 second", () => { + const obj = getObject({ durationSeconds: 60 * 60 * 2 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("2h 1s"); + }); + + it("formats 1 minute and 1 second", () => { + const obj = getObject({ durationSeconds: 61 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("1m 1s"); + }); + }); + + describe("Human-readable format (with showFullUnits)", () => { + it("formats 0 seconds as 0 seconds", () => { + const obj = getObject({ durationSecondsFullUnits: 0 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("0 seconds"); + }); + + it("formats 10 seconds as 10 seconds", () => { + const obj = getObject({ durationSecondsFullUnits: 10 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("10 seconds"); + }); + + it("formats 60 seconds as a minute", () => { + const obj = getObject({ durationSecondsFullUnits: 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 minute"); + }); + + it("formats 60 minutes as an hour", () => { + const obj = getObject({ durationSecondsFullUnits: 60 * 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 hour"); + }); + + it("formats 24 hours as a day", () => { + const obj = getObject({ durationSecondsFullUnits: 60 * 60 * 24 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 day"); + }); + + it("formats 2 days and 1 second", () => { + const obj = getObject({ durationSecondsFullUnits: 60 * 60 * 48 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("2 days 1 second"); + }); + + it("formats 2 days and 1 hour", () => { + const obj = getObject({ + durationSecondsFullUnits: 60 * 60 * 48 + 60 * 60, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("2 days 1 hour"); + }); + + it("formats 2 days and 1 min", () => { + const obj = getObject({ durationSecondsFullUnits: 60 * 60 * 48 + 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("2 days 1 minute"); + }); + + it("formats a day, 2 hours, 1 minute and 1 second", () => { + const obj = getObject({ + durationSecondsFullUnits: 60 * 60 * 24 + 60 * 60 * 2 + 60 + 1, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 day 2 hours 1 minute 1 second"); + }); + + it("formats 2 hours, 1 minute and 1 second", () => { + const obj = getObject({ + durationSecondsFullUnits: 60 * 60 * 2 + 60 + 1, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("2 hours 1 minute 1 second"); + }); + + it("formats 2 hours and 1 second", () => { + const obj = getObject({ durationSecondsFullUnits: 60 * 60 * 2 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("2 hours 1 second"); + }); + + it("formats 1 minute and 1 second", () => { + const obj = getObject({ durationSecondsFullUnits: 61 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 minute 1 second"); + }); + + it("formats negative numbers", () => { + const obj = getObject({ durationSecondsFullUnits: -1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 second"); + }); + + it("formats decimal numbers", () => { + const obj = getObject({ durationSecondsFullUnits: 1.84321 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 second"); + }); + + it("formats decimal numbers with minutes", () => { + const obj = getObject({ durationSecondsFullUnits: 62.1001 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSecondsFullUnits", + EN_US, + ), + ).toBe("1 minute 2 seconds"); + }); + }); + + describe("Timecode format", () => { + it("formats 0 seconds as 0:00", () => { + const obj = getObject({ durationTimecode: 0 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("0:00"); + }); + + it("formats 1 second as 0:01", () => { + const obj = getObject({ durationTimecode: 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("0:01"); + }); + + it("formats 10 seconds as 0:10", () => { + const obj = getObject({ durationTimecode: 10 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("0:10"); + }); + + it("formats 60 seconds as 1:00", () => { + const obj = getObject({ durationTimecode: 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("1:00"); + }); + + it("formats 60 minutes as 1:00:00", () => { + const obj = getObject({ durationTimecode: 60 * 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("1:00:00"); + }); + + it("formats 24 hours as 24:00:00", () => { + const obj = getObject({ durationTimecode: 60 * 60 * 24 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("24:00:00"); + }); + + it("formats 2 days and 1 second", () => { + const obj = getObject({ durationTimecode: 60 * 60 * 48 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("48:00:01"); + }); + + it("formats 2 days and 1 hour", () => { + const obj = getObject({ durationTimecode: 60 * 60 * 48 + 60 * 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("49:00:00"); + }); + + it("formats 2 days and 1 min", () => { + const obj = getObject({ durationTimecode: 60 * 60 * 48 + 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("48:01:00"); + }); + + it("formats a day, 2 hours, 1 minute and 1 second", () => { + const obj = getObject({ + durationTimecode: 60 * 60 * 24 + 60 * 60 * 2 + 60 + 1, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("26:01:01"); + }); + + it("formats 2 hours, 1 minute, 1 second and 123 milliseconds", () => { + const obj = getObject({ + durationTimecode: 60 * 60 * 2 + 60 + 1 + 0.1231421, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("2:01:01.123"); + }); + + it("formats 2 hours, 1 minute and 1 second", () => { + const obj = getObject({ durationTimecode: 60 * 60 * 2 + 60 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("2:01:01"); + }); + + it("formats 2 hours and 1 second", () => { + const obj = getObject({ durationTimecode: 60 * 60 * 2 + 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("2:00:01"); + }); + + it("formats 1 minute and 1 second", () => { + const obj = getObject({ durationTimecode: 61 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("1:01"); + }); + + it("formats 1 minute, 1 second and 100 milliseconds", () => { + const obj = getObject({ durationTimecode: 61 + 0.1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("1:01.100"); + }); + + it("formats negative numbers", () => { + const obj = getObject({ durationTimecode: -1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("0:01"); + }); + + it("formats decimal numbers", () => { + const obj = getObject({ durationTimecode: 1.84321 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("0:01.843"); + }); + + it("does not display decimals past 3", () => { + const obj = getObject({ durationTimecode: 0.000001 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationTimecode", + EN_US, + ), + ).toBe("0:00"); + }); + }); + + describe("Precision modes", () => { + it("formats with DAYS precision", () => { + const obj = getObject({ durationPrecisionDays: 0 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationPrecisionDays", + EN_US, + ), + ).toBe("0d"); + }); + + it("formats with HOURS precision", () => { + const obj = getObject({ durationPrecisionHours: 60 * 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationPrecisionHours", + EN_US, + ), + ).toBe("1h"); + }); + + it("formats with MINUTES precision", () => { + const obj = getObject({ durationPrecisionMinutes: 60 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationPrecisionMinutes", + EN_US, + ), + ).toBe("1m"); + }); + + it("formats with SECONDS precision", () => { + const obj = getObject({ durationPrecisionSeconds: 1 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationPrecisionSeconds", + EN_US, + ), + ).toBe("1s"); + }); + + it("formats complex duration with HOURS precision", () => { + const obj = getObject({ + durationPrecisionHours: 2 * 24 * 60 * 60 + 5 * 60 * 60 + 45 * 60 + + 48, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationPrecisionHours", + EN_US, + ), + ).toBe("2d 6h"); + }); + + it("formats complex duration with MINUTES precision", () => { + const obj = getObject({ + durationPrecisionMinutes: 2 * 24 * 60 * 60 + 5 * 60 * 60 + 45 * 60 + + 48, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationPrecisionMinutes", + EN_US, + ), + ).toBe("2d 5h 46m"); + }); + + it("formats complex duration with SECONDS precision", () => { + const obj = getObject({ + durationPrecisionSeconds: 2 * 24 * 60 * 60 + 5 * 60 * 60 + 45 * 60 + + 48, + }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationPrecisionSeconds", + EN_US, + ), + ).toBe("2d 5h 45m 48s"); + }); + }); + + describe("Milliseconds base value", () => { + it("converts milliseconds to seconds", () => { + const obj = getObject({ durationMilliseconds: 1000 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationMilliseconds", + EN_US, + ), + ).toBe("1s"); + }); + + it("converts 60000 milliseconds to a minute", () => { + const obj = getObject({ durationMilliseconds: 60000 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationMilliseconds", + EN_US, + ), + ).toBe("1m"); + }); + + it("handles fractional milliseconds", () => { + const obj = getObject({ durationMilliseconds: 1500 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationMilliseconds", + EN_US, + ), + ).toBe("1s"); + }); + }); + + describe("Edge cases", () => { + it("handles negative durations", () => { + const obj = getObject({ durationSeconds: -100 }); + expect( + obj.$__EXPERIMENTAL__NOT_SUPPORTED_YET__getFormattedValue( + "durationSeconds", + EN_US, + ), + ).toBe("1m 40s"); + }); + }); + }); }); diff --git a/packages/client/src/object/formatting/formatNumber.ts b/packages/client/src/object/formatting/formatNumber.ts index c5e15accb3..473bfb207d 100644 --- a/packages/client/src/object/formatting/formatNumber.ts +++ b/packages/client/src/object/formatting/formatNumber.ts @@ -15,9 +15,11 @@ */ import type { + DurationPrecision, NumberFormatAffix, NumberFormatCurrency, NumberFormatCustomUnit, + NumberFormatDuration, NumberFormatOptions, NumberFormatRatio, NumberFormatScale, @@ -72,8 +74,7 @@ export function formatNumber( return formatAffix(value, numberType, objectData, locale); case "duration": - // TODO (duration is a bit more complex) - return undefined; + return formatDuration(value, numberType); case "scale": return formatScale(value, numberType, locale); @@ -396,3 +397,233 @@ function formatNumberWithAffixes( const formatted = formatWithIntl(value, intlOptions, locale); return `${prefix || ""}${formatted}${suffix || ""}`; } + +// Duration formatting constants +const SECONDS_IN_MINUTE = 60; +const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60; +const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; +const MS_IN_SECOND = 1000; + +interface DurationComponents { + days: number; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; +} + +function convertDurationToSeconds( + value: number, + baseValue: "SECONDS" | "MILLISECONDS", +): number { + return baseValue === "MILLISECONDS" ? value / MS_IN_SECOND : value; +} + +function getDurationComponents( + seconds: number, + precision: DurationPrecision, +): DurationComponents { + const absSeconds = Math.abs(seconds); + + const days = Math.floor(absSeconds / SECONDS_IN_DAY); + const remainingAfterDays = absSeconds % SECONDS_IN_DAY; + const hours = Math.floor(remainingAfterDays / SECONDS_IN_HOUR); + const remainingAfterHours = absSeconds % SECONDS_IN_HOUR; + const minutes = Math.floor(remainingAfterHours / SECONDS_IN_MINUTE); + const remainingSeconds = absSeconds % SECONDS_IN_MINUTE; + + switch (precision) { + case "DAYS": + return { + days: Math.round(absSeconds / SECONDS_IN_DAY), + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }; + + case "HOURS": + return { + days, + hours: Math.round(remainingAfterDays / SECONDS_IN_HOUR), + minutes: 0, + seconds: 0, + milliseconds: 0, + }; + + case "MINUTES": + return { + days, + hours, + minutes: Math.round(remainingAfterHours / SECONDS_IN_MINUTE), + seconds: 0, + milliseconds: 0, + }; + + case "SECONDS": + return { + days, + hours, + minutes, + seconds: Math.round(remainingSeconds), + milliseconds: 0, + }; + + default: // AUTO + return { + days, + hours, + minutes, + seconds: Math.floor(remainingSeconds), + milliseconds: Math.round((remainingSeconds % 1) * MS_IN_SECOND), + }; + } +} + +function formatDurationUnit( + value: number, + singularUnit: string, + pluralUnit: string, + shortUnit: string, + showFullUnits: boolean, +): string { + if (showFullUnits) { + return `${value} ${value === 1 ? singularUnit : pluralUnit}`; + } + return `${value}${shortUnit}`; +} + +interface ComponentDisplayRules { + forceDisplayDays: boolean; + forceDisplayHours: boolean; + forceDisplayMinutes: boolean; + forceDisplaySeconds: boolean; +} + +function getComponentDisplayRules( + components: DurationComponents, + precision: DurationPrecision, +): ComponentDisplayRules { + const { days, hours, minutes, seconds } = components; + + if (precision === "AUTO") { + const isZeroDuration = days === 0 && hours === 0 && minutes === 0 + && seconds === 0; + return { + forceDisplayDays: days > 0, + forceDisplayHours: hours > 0, + forceDisplayMinutes: minutes > 0, + forceDisplaySeconds: seconds > 0 || isZeroDuration, + }; + } + + switch (precision) { + case "DAYS": + return { + forceDisplayDays: true, + forceDisplayHours: false, + forceDisplayMinutes: false, + forceDisplaySeconds: false, + }; + + case "HOURS": + return { + forceDisplayDays: days > 0, + forceDisplayHours: true, + forceDisplayMinutes: false, + forceDisplaySeconds: false, + }; + + case "MINUTES": + return { + forceDisplayDays: days > 0, + forceDisplayHours: hours > 0 || days > 0, + forceDisplayMinutes: true, + forceDisplaySeconds: false, + }; + + case "SECONDS": + return { + forceDisplayDays: days > 0, + forceDisplayHours: hours > 0 || days > 0, + forceDisplayMinutes: minutes > 0 || hours > 0 || days > 0, + forceDisplaySeconds: true, + }; + + default: + precision satisfies never; + return { + forceDisplayDays: false, + forceDisplayHours: false, + forceDisplayMinutes: false, + forceDisplaySeconds: false, + }; + } +} + +function formatHumanReadable( + components: DurationComponents, + precision: DurationPrecision, + showFullUnits: boolean, +): string { + const parts: string[] = []; + const { days, hours, minutes, seconds } = components; + const displayRules = getComponentDisplayRules(components, precision); + + if (displayRules.forceDisplayDays) { + parts.push(formatDurationUnit(days, "day", "days", "d", showFullUnits)); + } + if (displayRules.forceDisplayHours) { + parts.push(formatDurationUnit(hours, "hour", "hours", "h", showFullUnits)); + } + if (displayRules.forceDisplayMinutes) { + parts.push( + formatDurationUnit(minutes, "minute", "minutes", "m", showFullUnits), + ); + } + if (displayRules.forceDisplaySeconds) { + parts.push( + formatDurationUnit(seconds, "second", "seconds", "s", showFullUnits), + ); + } + + return parts.join(" "); +} + +function formatTimecode(components: DurationComponents): string { + const { days, hours, minutes, seconds, milliseconds } = components; + + const totalHours = days * 24 + hours; + const pad2 = (num: number) => String(num).padStart(2, "0"); + const pad3 = (num: number) => String(num).padStart(3, "0"); + + const hasHours = totalHours > 0; + const hasMilliseconds = milliseconds > 0; + + if (hasHours) { + const base = `${totalHours}:${pad2(minutes)}:${pad2(seconds)}`; + return hasMilliseconds ? `${base}.${pad3(milliseconds)}` : base; + } + + const base = `${minutes}:${pad2(seconds)}`; + return hasMilliseconds ? `${base}.${pad3(milliseconds)}` : base; +} + +function formatDuration( + value: number, + rule: NumberFormatDuration, +): string { + const seconds = convertDurationToSeconds(value, rule.baseValue); + const precision = rule.precision ?? "AUTO"; + const components = getDurationComponents(seconds, precision); + + if (rule.formatStyle.type === "timecode") { + return formatTimecode(components); + } + + return formatHumanReadable( + components, + precision, + rule.formatStyle.showFullUnits ?? false, + ); +}