diff --git a/mathesar_ui/src/utils/duration/DurationFormatter.ts b/mathesar_ui/src/utils/duration/DurationFormatter.ts index 0a08df3917..d5593742f8 100644 --- a/mathesar_ui/src/utils/duration/DurationFormatter.ts +++ b/mathesar_ui/src/utils/duration/DurationFormatter.ts @@ -10,6 +10,55 @@ import type { import type DurationSpecification from './DurationSpecification'; const FLOAT_REGEX = /^((\.?\d+)|(\d+(\.\d+)?))$/; +function createDurationISO( + unitWithValue: Partial>, +): string { + return dayjs + .duration({ + years: 0, + months: 0, + days: unitWithValue.d ?? 0, + hours: unitWithValue.h ?? 0, + minutes: unitWithValue.m ?? 0, + seconds: unitWithValue.s ?? 0, + milliseconds: unitWithValue.ms ?? 0, + }) + .toISOString(); +} +function parseTextBasedDuration(userInput: string): string | null { + const cleanedInput = userInput.trim().toLowerCase(); + if (cleanedInput === '') { + return null; + } + + const durationPattern = /(\d+(?:\.\d+)?)\s*(ms|[dhms])/g; + const matches = Array.from(cleanedInput.matchAll(durationPattern)); + + if (matches.length === 0) { + return null; + } + + const matchedText = matches.map((m) => m[0]).join(''); + const inputWithoutSpaces = cleanedInput.replace(/\s+/g, ''); + if (matchedText.replace(/\s+/g, '') !== inputWithoutSpaces) { + return null; + } + + const unitWithValue: Partial> = {}; + + for (const match of matches) { + const value = parseFloat(match[1]); + const unit = match[2] as DurationUnit; + + if (Number.isNaN(value)) { + return null; + } + + unitWithValue[unit] = (unitWithValue[unit] ?? 0) + value; + } + + return createDurationISO(unitWithValue); +} function parseRawDurationStringToISOString( specification: DurationSpecification, @@ -20,27 +69,19 @@ function parseRawDurationStringToISOString( unitsInRange = unitsInRange.slice(0, unitsInRange.length - 1); } - let cleanedInput = userInput.trim(); + const cleanedInput = userInput.trim(); if (cleanedInput === '') { return null; } - const firstEntry = cleanedInput[0]; - if (firstEntry === ':') { - cleanedInput = `0${cleanedInput}`; - } + const normalized = cleanedInput.replace(/^:/, '0:').replace(/[:.]$/, '$&0'); - const lastEntry = cleanedInput[cleanedInput.length - 1]; - if (lastEntry === ':' || lastEntry === '.') { - cleanedInput = `${cleanedInput}0`; - } - - const unitValues = cleanedInput.split(':'); + const unitValues = normalized.split(':'); if (unitValues.length > unitsInRange.length) { throw new Error('Duration exceeds specified unit range'); } - const terms = cleanedInput + const terms = normalized .split(':') .map((entry) => { let valueString = entry; @@ -69,16 +110,7 @@ function parseRawDurationStringToISOString( unitWithValue[unit] = terms[index] ?? 0; }); - return dayjs - .duration({ - years: 0, - months: 0, - days: unitWithValue.d ?? 0, - hours: unitWithValue.h ?? 0, - minutes: unitWithValue.m ?? 0, - seconds: unitWithValue.s ?? 0, - }) - .toISOString(); + return createDurationISO(unitWithValue); } const unitConfig: Record< @@ -119,9 +151,12 @@ function shiftAndFormatISODurationString( canonicalValue: string, specification: DurationSpecification, ): string { - const unitsWithValues: Partial> = {}; const duration = dayjs.duration(canonicalValue); + if (!duration || Number.isNaN(duration.asMilliseconds())) { + return '00:00'; + } + const units = specification.getUnitsInRange().reverse(); if (units.length === 0) { @@ -132,6 +167,12 @@ function shiftAndFormatISODurationString( let currentUnitConfig = unitConfig[units[0]]; let currentUnitValue = currentUnitConfig.convert(duration); + if (Number.isNaN(currentUnitValue)) { + return '00:00'; + } + + const unitsWithValues: Partial> = {}; + for (const unit of units) { const higherUnit = specification.getHigherUnit(unit); let higherUnitValue = 0; @@ -146,18 +187,39 @@ function shiftAndFormatISODurationString( dayjs.duration(higherUnitValue, higherUnitConfig.unitName), ); } - unitsWithValues[currentUnitConfig.unitName] = parseFloat( + const finalValue = parseFloat( currentUnitValue.toFixed(currentUnitConfig.decimalCorrection ?? 3), ); + unitsWithValues[unit] = finalValue; if (higherUnit) { currentUnitValue = higherUnitValue; currentUnitConfig = unitConfig[higherUnit]; } } - return dayjs - .duration(unitsWithValues) - .format(specification.getFormattingString()); + // Manually format the duration string based on the specification + const unitsInRange = specification.getUnitsInRange(); + + // Build the formatted string + const formatUnit = (unit: DurationUnit, value: number): string => { + const floorValue = Math.floor(value).toString(); + if (unit === 'd') return floorValue; + return unit === 'ms' + ? floorValue.padStart(3, '0') + : floorValue.padStart(2, '0'); + }; + + const parts = unitsInRange.map((unit) => + formatUnit(unit, unitsWithValues[unit] ?? 0), + ); + + // Join with : or . depending on milliseconds + if (unitsInRange[unitsInRange.length - 1] === 'ms' && parts.length > 1) { + const msValue = parts.pop(); + return `${parts.join(':')}.${msValue ?? '000'}`; + } + + return parts.join(':'); } export default class DurationFormatter implements InputFormatter { @@ -168,6 +230,15 @@ export default class DurationFormatter implements InputFormatter { } parse(userInput: string): ParseResult { + const textBasedValue = parseTextBasedDuration(userInput); + + if (textBasedValue !== null) { + return { + value: textBasedValue, + intermediateDisplay: userInput, + }; + } + const value = parseRawDurationStringToISOString( this.specification, userInput, @@ -178,7 +249,50 @@ export default class DurationFormatter implements InputFormatter { }; } + private buildISOString( + days: number, + hours: number, + minutes: number, + seconds: number, + ): string { + const dayPart = days > 0 ? `${days}D` : ''; + const hourPart = hours > 0 ? `${hours}H` : ''; + const minutePart = minutes > 0 ? `${minutes}M` : ''; + const secondPart = seconds > 0 ? `${seconds}S` : ''; + + const timePart = hourPart + minutePart + secondPart; + + if (!dayPart && !timePart) { + return 'PT0S'; + } + + return `P${dayPart}T${timePart}`; + } + format(canonicalValue: string): string { - return shiftAndFormatISODurationString(canonicalValue, this.specification); + const daysMatch = canonicalValue.match( + /^(\d+) days? (\d+):(\d+):(\d+(?:\.\d+)?)$/, + ); + const timeMatch = canonicalValue.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/); + + let isoString = canonicalValue; + + if (daysMatch) { + isoString = this.buildISOString( + parseInt(daysMatch[1], 10), + parseInt(daysMatch[2], 10), + parseInt(daysMatch[3], 10), + parseFloat(daysMatch[4]), + ); + } else if (timeMatch) { + isoString = this.buildISOString( + 0, + parseInt(timeMatch[1], 10), + parseInt(timeMatch[2], 10), + parseFloat(timeMatch[3]), + ); + } + + return shiftAndFormatISODurationString(isoString, this.specification); } }