Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 142 additions & 28 deletions mathesar_ui/src/utils/duration/DurationFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,55 @@ import type {
import type DurationSpecification from './DurationSpecification';

const FLOAT_REGEX = /^((\.?\d+)|(\d+(\.\d+)?))$/;
function createDurationISO(
unitWithValue: Partial<Record<DurationUnit, number>>,
): 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<Record<DurationUnit, number>> = {};

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,
Expand All @@ -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;
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -119,9 +151,12 @@ function shiftAndFormatISODurationString(
canonicalValue: string,
specification: DurationSpecification,
): string {
const unitsWithValues: Partial<Record<DurationUnitType, number>> = {};
const duration = dayjs.duration(canonicalValue);

if (!duration || Number.isNaN(duration.asMilliseconds())) {
return '00:00';
}

const units = specification.getUnitsInRange().reverse();

if (units.length === 0) {
Expand All @@ -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<Record<DurationUnit, number>> = {};

for (const unit of units) {
const higherUnit = specification.getHigherUnit(unit);
let higherUnitValue = 0;
Expand All @@ -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<string> {
Expand All @@ -168,6 +230,15 @@ export default class DurationFormatter implements InputFormatter<string> {
}

parse(userInput: string): ParseResult<string> {
const textBasedValue = parseTextBasedDuration(userInput);

if (textBasedValue !== null) {
return {
value: textBasedValue,
intermediateDisplay: userInput,
};
}

const value = parseRawDurationStringToISOString(
this.specification,
userInput,
Expand All @@ -178,7 +249,50 @@ export default class DurationFormatter implements InputFormatter<string> {
};
}

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);
}
}
Loading