diff --git a/package-lock.json b/package-lock.json index 6d1c757f1e..c2bc524779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,7 @@ "stylelint-order": "^6.0.4", "svelte-check": "^3.4.4", "tslib": "^2.6.0", - "typescript": "5.4.3", + "typescript": "5.0.4", "unique-names-generator": "^4.7.1", "vite": "5.1.6", "vitest": "^1.0.0" @@ -7345,16 +7345,16 @@ } }, "node_modules/typescript": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", - "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=12.20" } }, "node_modules/ufo": { diff --git a/package.json b/package.json index 42bc97ff16..4b39ce8404 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "stylelint-order": "^6.0.4", "svelte-check": "^3.4.4", "tslib": "^2.6.0", - "typescript": "5.4.3", + "typescript": "5.0.4", "unique-names-generator": "^4.7.1", "vite": "5.1.6", "vitest": "^1.0.0" diff --git a/src/components/model/ModelForm.svelte b/src/components/model/ModelForm.svelte index beb486a117..60523d678f 100644 --- a/src/components/model/ModelForm.svelte +++ b/src/components/model/ModelForm.svelte @@ -46,10 +46,9 @@ let owner: UserId | null = null; let version: string = ''; let description: string = ''; - let modelLogs: Pick< - ModelSlim, - 'refresh_activity_type_logs' | 'refresh_model_parameter_logs' | 'refresh_resource_type_logs' - > | null = null; + let modelLogs: + | Pick + | undefined = undefined; $: description = initialModelDescription; $: name = initialModelName; diff --git a/src/components/sequencing/form/SelectedCommand.svelte b/src/components/sequencing/form/SelectedCommand.svelte index d258671924..63f38b2deb 100644 --- a/src/components/sequencing/form/SelectedCommand.svelte +++ b/src/components/sequencing/form/SelectedCommand.svelte @@ -172,7 +172,7 @@ } } - function hasAncestorWithId(element: Element | null, id: string) { + function hasAncestorWithId(element: Element | null, id: string): boolean { if (element === null) { return false; } else if (element.id === id) { diff --git a/src/components/ui/DatePicker/DatePicker.svelte b/src/components/ui/DatePicker/DatePicker.svelte index ac2488fbd9..dc33c3c495 100644 --- a/src/components/ui/DatePicker/DatePicker.svelte +++ b/src/components/ui/DatePicker/DatePicker.svelte @@ -166,7 +166,7 @@ * Converts a date string (YYYY-MM-DDTHH:mm:ss) or DOY string (YYYY-DDDDTHH:mm:ss) into a Date object */ function getDateFromString(dateString: string): Date | null { - const parsedDate = parseDoyOrYmdTime(dateString); + const parsedDate = parseDoyOrYmdTime(dateString) as ParsedYmdString | ParsedDoyString; if (parsedDate !== null) { const { hour, min, ms, sec, year } = parsedDate; diff --git a/src/enums/time.ts b/src/enums/time.ts index db99198347..c33974d2cb 100644 --- a/src/enums/time.ts +++ b/src/enums/time.ts @@ -7,3 +7,10 @@ export enum TIME_MS { MONTH = 2629800000, YEAR = 31557600000, } +export enum TimeTypes { + ABSOLUTE = 'absolute', + EPOCH = 'epoch', + EPOCH_SIMPLE = 'epoch_simple', + RELATIVE = 'relative', + RELATIVE_SIMPLE = 'relative_simple', +} diff --git a/src/types/time.ts b/src/types/time.ts index 8610a9042e..8cad359001 100644 --- a/src/types/time.ts +++ b/src/types/time.ts @@ -18,6 +18,16 @@ export type ParsedDurationString = { seconds: number; years: number; }; +export type DurationTimeComponents = { + days: string; + hours: string; + isNegative: string; + microseconds: string; + milliseconds: string; + minutes: string; + seconds: string; + years: string; +}; export type ParsedYmdString = { day: number; diff --git a/src/utilities/monacoHelper.ts b/src/utilities/monacoHelper.ts index aa203993fc..3e9a055c8c 100644 --- a/src/utilities/monacoHelper.ts +++ b/src/utilities/monacoHelper.ts @@ -13,7 +13,7 @@ export const sequenceProvideCodeActions = ( .map(unbalancedTime => { const match = unbalancedTime.message.match(/Suggestion:\s*(.*)/); if (match) { - const extractSuggestedTime = match[1].replace(/\s+/g, ''); + const extractSuggestedTime = match[1].replace(/\s+|[\\[|\]]/g, ''); return generateQuickFixAction('Convert Unbalanced Time', extractSuggestedTime, unbalancedTime, model); } return undefined; // Return undefined when the match fails @@ -42,7 +42,12 @@ function generateQuickFixAction( { resource: model.uri, textEdit: { - range: diagnostics, + range: { + endColumn: diagnostics.endColumn - 1, + endLineNumber: diagnostics.endLineNumber, + startColumn: diagnostics.startColumn + 2, + startLineNumber: diagnostics.startLineNumber, + }, text: replaceText, }, versionId: model.getVersionId(), diff --git a/src/utilities/new-sequence-editor/sequence-completion.ts b/src/utilities/new-sequence-editor/sequence-completion.ts index aca98d652c..71422b878f 100644 --- a/src/utilities/new-sequence-editor/sequence-completion.ts +++ b/src/utilities/new-sequence-editor/sequence-completion.ts @@ -1,6 +1,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { syntaxTree } from '@codemirror/language'; import type { ChannelDictionary, CommandDictionary, ParameterDictionary } from '@nasa-jpl/aerie-ampcs'; +import { getDoyTime } from '../time'; import { fswCommandArgDefault } from './command-dictionary'; import { getCustomArgDef } from './extension-points'; @@ -103,9 +104,17 @@ export function sequenceCompletion( ); if (!cursor.isTimeTagBefore) { + //get the first of the year date + const date = new Date(); + date.setMonth(0); + date.setDate(1); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); timeTagCompletions.push( { - apply: 'A0000-000T00:00:00 ', + apply: `A${getDoyTime(date)} `, info: 'Execute command at an absolute time', label: `A (absolute)`, section: 'Time Tags', @@ -119,7 +128,7 @@ export function sequenceCompletion( type: 'keyword', }, { - apply: 'E+00:00:00 ', + apply: 'E1 ', info: 'Execute command at an offset from an epoch', label: 'E (epoch)', section: 'Time Tags', diff --git a/src/utilities/new-sequence-editor/sequence-linter.ts b/src/utilities/new-sequence-editor/sequence-linter.ts index 9b27e6501b..9c80017a1a 100644 --- a/src/utilities/new-sequence-editor/sequence-linter.ts +++ b/src/utilities/new-sequence-editor/sequence-linter.ts @@ -15,18 +15,20 @@ import { closest, distance } from 'fastest-levenshtein'; import type { VariableDeclaration } from '@nasa-jpl/seq-json-schema/types'; import type { EditorView } from 'codemirror'; +import { TimeTypes } from '../../enums/time'; +import { CustomErrorCodes } from '../../workers/customCodes'; import { addDefaultArgs, quoteEscape } from '../codemirror/codemirror-utils'; -import { getCustomArgDef } from './extension-points'; -import { TOKEN_COMMAND, TOKEN_ERROR, TOKEN_REPEAT_ARG } from './sequencer-grammar-constants'; import { - ABSOLUTE_TIME, - EPOCH_SIMPLE, - EPOCH_TIME, - RELATIVE_SIMPLE, - RELATIVE_TIME, + getBalancedDuration, + getDoyTime, + getUnixEpochTime, isTimeBalanced, - testTime, -} from './time-utils'; + isTimeMax, + parseDurationString, + validateTime, +} from '../time'; +import { getCustomArgDef } from './extension-points'; +import { TOKEN_COMMAND, TOKEN_ERROR, TOKEN_REPEAT_ARG } from './sequencer-grammar-constants'; import { getChildrenNode, getDeepestNode, getFromAndTo } from './tree-utils'; const KNOWN_DIRECTIVES = [ @@ -481,101 +483,103 @@ export function sequenceLinter( if (timeTagAbsoluteNode) { const absoluteText = text.slice(timeTagAbsoluteNode.from + 1, timeTagAbsoluteNode.to).trim(); - if (!testTime(absoluteText, ABSOLUTE_TIME)) { + const isValid = validateTime(absoluteText, TimeTypes.ABSOLUTE); + if (!isValid) { diagnostics.push({ actions: [], from: timeTagAbsoluteNode.from, - message: `Time Error: Incorrectly formatted 'Absolute' time. - Received : Malformed Absolute time. - Expected: YYYY-DOYThh:mm:ss[.sss]`, + message: CustomErrorCodes.InvalidAbsoluteTime().message, severity: 'error', to: timeTagAbsoluteNode.to, }); } else { - const result = isTimeBalanced(absoluteText, ABSOLUTE_TIME); - if (result.error) { + if (isTimeMax(absoluteText, TimeTypes.ABSOLUTE)) { diagnostics.push({ actions: [], from: timeTagAbsoluteNode.from, - message: result.error, + message: CustomErrorCodes.MaxAbsoluteTime().message, severity: 'error', to: timeTagAbsoluteNode.to, }); - } else if (result.warning) { - diagnostics.push({ - actions: [], - from: timeTagAbsoluteNode.from, - message: result.warning, - severity: 'warning', - to: timeTagAbsoluteNode.to, - }); + } else { + if (!isTimeBalanced(absoluteText, TimeTypes.ABSOLUTE)) { + diagnostics.push({ + actions: [], + from: timeTagAbsoluteNode.from, + message: CustomErrorCodes.UnbalancedTime(getDoyTime(new Date(getUnixEpochTime(absoluteText)))) + .message, + severity: 'warning', + to: timeTagAbsoluteNode.to, + }); + } } } } else if (timeTagEpochNode) { const epochText = text.slice(timeTagEpochNode.from + 1, timeTagEpochNode.to).trim(); - if (!testTime(epochText, EPOCH_TIME) && !testTime(epochText, EPOCH_SIMPLE)) { + const isValid = validateTime(epochText, TimeTypes.EPOCH) || validateTime(epochText, TimeTypes.EPOCH_SIMPLE); + if (!isValid) { diagnostics.push({ actions: [], from: timeTagEpochNode.from, - message: `Time Error: Incorrectly formatted 'Epoch' time. - Received : Malformed Epoch time. - Expected: YYYY-DOYThh:mm:ss[.sss] or [+/-]ss`, + message: CustomErrorCodes.InvalidEpochTime().message, severity: 'error', to: timeTagEpochNode.to, }); } else { - if (testTime(epochText, EPOCH_TIME)) { - const result = isTimeBalanced(epochText, EPOCH_TIME); - if (result.error) { + if (validateTime(epochText, TimeTypes.EPOCH)) { + if (isTimeMax(epochText, TimeTypes.EPOCH)) { diagnostics.push({ actions: [], from: timeTagEpochNode.from, - message: result.error, + message: CustomErrorCodes.MaxEpochTime(parseDurationString(epochText, 'seconds').isNegative).message, severity: 'error', to: timeTagEpochNode.to, }); - } else if (result.warning) { - diagnostics.push({ - actions: [], - from: timeTagEpochNode.from, - message: result.warning, - severity: 'warning', - to: timeTagEpochNode.to, - }); + } else { + if (!isTimeBalanced(epochText, TimeTypes.EPOCH)) { + diagnostics.push({ + actions: [], + from: timeTagEpochNode.from, + message: CustomErrorCodes.UnbalancedTime(getBalancedDuration(epochText)).message, + severity: 'warning', + to: timeTagEpochNode.to, + }); + } } } } } else if (timeTagRelativeNode) { const relativeText = text.slice(timeTagRelativeNode.from + 1, timeTagRelativeNode.to).trim(); - if (!testTime(relativeText, RELATIVE_TIME) && !testTime(relativeText, RELATIVE_SIMPLE)) { + const isValid = + validateTime(relativeText, TimeTypes.RELATIVE) || validateTime(relativeText, TimeTypes.RELATIVE_SIMPLE); + if (!isValid) { diagnostics.push({ actions: [], from: timeTagRelativeNode.from, - message: `Time Error: Incorrectly formatted 'Relative' time. - Received : Malformed Relative time. - Expected: [+/-]hh:mm:ss[.sss]`, + message: CustomErrorCodes.InvalidRelativeTime().message, severity: 'error', to: timeTagRelativeNode.to, }); } else { - if (testTime(relativeText, RELATIVE_TIME)) { - const result = isTimeBalanced(relativeText, RELATIVE_TIME); - if (result.error) { + if (validateTime(relativeText, TimeTypes.RELATIVE)) { + if (isTimeMax(relativeText, TimeTypes.RELATIVE)) { diagnostics.push({ actions: [], from: timeTagRelativeNode.from, - message: result.error, + message: CustomErrorCodes.MaxRelativeTime().message, severity: 'error', to: timeTagRelativeNode.to, }); - } else if (result.warning) { - diagnostics.push({ - actions: [], - from: timeTagRelativeNode.from, - message: result.warning, - severity: 'warning', - to: timeTagRelativeNode.to, - }); + } else { + if (!isTimeBalanced(relativeText, TimeTypes.EPOCH)) { + diagnostics.push({ + actions: [], + from: timeTagRelativeNode.from, + message: CustomErrorCodes.UnbalancedTime(getBalancedDuration(relativeText)).message, + severity: 'error', + to: timeTagRelativeNode.to, + }); + } } } } diff --git a/src/utilities/new-sequence-editor/time-utils.ts b/src/utilities/new-sequence-editor/time-utils.ts deleted file mode 100644 index 3004f5de1b..0000000000 --- a/src/utilities/new-sequence-editor/time-utils.ts +++ /dev/null @@ -1,249 +0,0 @@ -export const ABSOLUTE_TIME = /^(\d{4})-(\d{3})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?$/g; - -export const RELATIVE_TIME = /([0-9]{3}T)?([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?$/g; -export const RELATIVE_SIMPLE = /(\d+)(\.[0-9]+)?$/g; - -export const EPOCH_TIME = /(^[+-]?)([0-9]{3}T)?([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?$/g; -export const EPOCH_SIMPLE = /(^[+-]?)(\d+)(\.[0-9]+)?$/g; - -/** - * Tests if a given time string matches a specified regular expression. - * - * @param {string} time - The time string to be tested. - * @param {RegExp} regex - The regular expression to test against. - * @return {RegExpExecArray | null} The result of the regular expression execution, or null if no match is found. - */ -export function testTime(time: string, regex: RegExp): RegExpExecArray | null { - regex.lastIndex = 0; - return regex.exec(time); -} - -export function isTimeBalanced( - time: string, - regex: RegExp, -): { error?: string | undefined; warning?: string | undefined } { - const { years, days, hours, minutes, seconds, milliseconds } = extractTime(time, regex); - - if (regex === ABSOLUTE_TIME && years !== undefined && days !== undefined) { - const isUnbalanced = - (years >= 0 && - years <= 9999 && - days >= 0 && - days <= (isLeapYear(years) ? 366 : 365) && - hours >= 0 && - hours <= 23 && - minutes >= 0 && - minutes <= 59 && - seconds >= 0 && - seconds <= 59) === false; - - if (isUnbalanced) { - return balanceAbsolute(years, days, hours, minutes, seconds, milliseconds); - } - } else { - const isUnbalanced = - (days !== undefined - ? days >= 1 && - days <= 365 && - hours >= 0 && - hours <= 23 && - minutes >= 0 && - minutes <= 59 && - seconds >= 0 && - seconds <= 59 - : hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59) === false; - - if (isUnbalanced) { - return balanceDuration(days ?? 0, hours, minutes, seconds, milliseconds); - } - } - - return {}; -} - -function extractTime( - time: string, - regex: RegExp, -): { - days?: number; - hours: number; - milliseconds: number; - minutes: number; - seconds: number; - years?: number; -} { - regex.lastIndex = 0; - const matches = regex.exec(time); - - if (!matches) { - return { hours: 0, milliseconds: 0, minutes: 0, seconds: 0 }; - } - - if (regex.source === ABSOLUTE_TIME.source) { - const [, years = '0', days = '0', hours = '0', minutes = '0', seconds = '0', milliseconds = '0'] = matches; - const [yearsNum, daysNum, hoursNum, minuteNum, secondsNum, millisecondNum] = [ - years, - days, - hours, - minutes, - seconds, - milliseconds, - ].map(Number); - return { - days: daysNum, - hours: hoursNum, - milliseconds: millisecondNum, - minutes: minuteNum, - seconds: secondsNum, - years: yearsNum, - }; - } - if (regex.source === EPOCH_TIME.source) { - const [, , days = undefined, hours = '0', minutes = '0', seconds = '0', milliseconds = '0'] = matches; - const [hoursNum, minuteNum, secondsNum, millisecondNum] = [hours, minutes, seconds, milliseconds].map(Number); - const daysNum = days !== undefined ? Number(days.replace('T', '')) : days; - return { - days: daysNum, - hours: hoursNum, - milliseconds: millisecondNum, - minutes: minuteNum, - seconds: secondsNum, - }; - } else if (regex.source === RELATIVE_TIME.source) { - const [, days = undefined, hours = '0', minutes = '0', seconds = '0', milliseconds = '0'] = matches; - const [hoursNum, minuteNum, secondsNum, millisecondNum] = [hours, minutes, seconds, milliseconds].map(Number); - const daysNum = days !== undefined ? Number(days.replace('T', '')) : days; - return { - days: daysNum, - hours: hoursNum, - milliseconds: millisecondNum, - minutes: minuteNum, - seconds: secondsNum, - }; - } - - return { hours: 0, milliseconds: 0, minutes: 0, seconds: 0 }; -} - -function balanceDuration( - unbalanceDays: number, - unbalancedHours: number, - unbalanceMinutes: number, - unbalanceSeconds: number, - unbalanceMilliseconds: number, -): { error?: string | undefined; warning?: string | undefined } { - const { days, hours, minutes, seconds, milliseconds } = normalizeTime( - unbalanceDays, - unbalancedHours, - unbalanceMinutes, - unbalanceSeconds, - unbalanceMilliseconds, - ); - - const DD = days !== 0 ? `${formatNumber(days, 3)}T` : ''; - const HH = days !== 0 ? formatNumber(hours, 2) : formatNumber(hours, 2); - const MM = formatNumber(minutes, 2); - const SS = formatNumber(seconds, 2); - const sss = formatNumber(milliseconds, 3); - - const balancedTime = `${DD}${HH}:${MM}:${SS}[.${sss}]`; - - if (days > 365) { - return { - error: `Time Error: Maximum time reached. - Received: Balanced time - ${balancedTime}. - Expected: ${balancedTime} <= 365T23:59:59.999`, - }; - } else { - return { - warning: `Time Warning: Unbalanced time used. - Suggestion: ${balancedTime}`, - }; - } -} - -function balanceAbsolute( - unbalanceYears: number, - unbalanceDays: number, - unbalancedHours: number, - unbalanceMinutes: number, - unbalanceSeconds: number, - unbalanceMilliseconds: number, -): { error?: string | undefined; warning?: string | undefined } { - const { years, days, hours, minutes, seconds, milliseconds } = normalizeTime( - unbalanceDays, - unbalancedHours, - unbalanceMinutes, - unbalanceSeconds, - unbalanceMilliseconds, - unbalanceYears, - ); - - const YY = years !== 0 && years !== undefined ? `${formatNumber(years, 4)}-` : ''; - const DD = (years !== 0 && days === 0) || days !== 0 ? `${formatNumber(days, 3)}T` : ''; - const HH = days !== 0 ? formatNumber(hours, 2) : formatNumber(hours, 2); - const MM = formatNumber(minutes, 2); - const SS = formatNumber(seconds, 2); - const sss = formatNumber(milliseconds, 3); - - const balancedTime = `${YY}${DD}${HH}:${MM}:${SS}.${sss}`; - - if (years && years > 9999) { - return { - error: `Time Error: Maximum time reached - Received: Balanced time - ${balancedTime}. - Expected: ${balancedTime} <= 9999-365T23:59:59.999`, - }; - } - - return { - warning: `Time Warning: Unbalanced time used. - Suggestion: ${balancedTime}`, - }; -} - -function normalizeTime( - days: number, - hours: number, - minutes: number, - seconds: number, - milliseconds: number, - years?: number, -): { days: number; hours: number; milliseconds: number; minutes: number; seconds: number; years?: number } { - // Normalize milliseconds and seconds - seconds += Math.floor(milliseconds / 1000); - milliseconds = milliseconds % 1000; - - // Normalize seconds and minutes - minutes += Math.floor(seconds / 60); - seconds = seconds % 60; - - // Normalize minutes and hours - hours += Math.floor(minutes / 60); - minutes = minutes % 60; - - // Normalize hours and days - days += Math.floor(hours / 24); - hours = hours % 24; - - // Normalize days and years - if (years !== undefined) { - const isLY = isLeapYear(years); - years += Math.floor(days / (isLY ? 366 : 365)); - days = days % (isLY ? 366 : 365); - } - - // Return the normalized values - return { days, hours, milliseconds, minutes, seconds, years }; -} - -function isLeapYear(year: number): boolean { - return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; -} - -function formatNumber(number: number, size: number): string { - const isNegative = number < 0; - const absoluteNumber = Math.abs(number).toString(); - const formattedNumber = absoluteNumber.padStart(size, '0'); - return isNegative ? `-${formattedNumber}` : formattedNumber; -} diff --git a/src/utilities/new-sequence-editor/to-seq-json.ts b/src/utilities/new-sequence-editor/to-seq-json.ts index 02dc92482d..ad624edd07 100644 --- a/src/utilities/new-sequence-editor/to-seq-json.ts +++ b/src/utilities/new-sequence-editor/to-seq-json.ts @@ -23,11 +23,12 @@ import type { Time, VariableDeclaration, } from '@nasa-jpl/seq-json-schema/types'; +import { TimeTypes } from '../../enums/time'; import { removeEscapedQuotes, unquoteUnescape } from '../codemirror/codemirror-utils'; +import { getBalancedDuration, getDurationTimeComponents, parseDurationString, validateTime } from '../time'; import { customizeSeqJson } from './extension-points'; import { logInfo } from './logger'; import { TOKEN_REPEAT_ARG } from './sequencer-grammar-constants'; -import { EPOCH_SIMPLE, EPOCH_TIME, RELATIVE_SIMPLE, RELATIVE_TIME, testTime } from './time-utils'; /** * Returns a minimal valid Seq JSON object. @@ -251,60 +252,45 @@ export function parseTime(commandNode: SyntaxNode, text: string): Time { const timeTagEpochText = text.slice(timeTagEpochNode.from + 1, timeTagEpochNode.to).trim(); // a regex to determine if this string [+/-]####T##:##:##.### - let match = testTime(timeTagEpochText, EPOCH_TIME); - if (match) { - const [, sign, doy, hh, mm, ss, ms] = match; - tag = `${sign === '-' ? '-' : ''}${doy !== undefined ? doy : ''}${hh ? hh : '00'}:${mm ? mm : '00'}:${ - ss ? ss : '00' - }${ms ? ms : ''}`; + if (validateTime(timeTagEpochText, TimeTypes.EPOCH)) { + const { isNegative, days, hours, minutes, seconds, milliseconds } = getDurationTimeComponents( + parseDurationString(timeTagEpochText, 'seconds'), + ); + tag = `${isNegative}${days}${hours}:${minutes}:${seconds}${milliseconds}`; return { tag, type: 'EPOCH_RELATIVE' }; } // a regex to determine if this string [+/-]###.### - match = testTime(timeTagEpochText, EPOCH_SIMPLE); - if (match) { - const [, sign, second, ms] = match; - tag = `${sign === '-' ? '-' : ''}${second ? secondsToHMS(Number(second)) : ''}${ms ? ms : ''}`; + if (validateTime(timeTagEpochText, TimeTypes.EPOCH_SIMPLE)) { + tag = getBalancedDuration(timeTagEpochText); + if (parseDurationString(tag, 'seconds').milliseconds === 0) { + tag = tag.slice(0, -4); + } return { tag, type: 'EPOCH_RELATIVE' }; } } else if (timeTagRelativeNode) { const timeTagRelativeText = text.slice(timeTagRelativeNode.from + 1, timeTagRelativeNode.to).trim(); // a regex to determine if this string ####T##:##:##.### - let match = testTime(timeTagRelativeText, RELATIVE_TIME); - if (match) { - RELATIVE_TIME.lastIndex = 0; - const [, doy, hh, mm, ss, ms] = match; - tag = `${doy !== undefined ? doy : ''}${hh ? hh : '00'}:${mm ? mm : '00'}:${ss ? ss : '00'}${ms ? ms : ''}`; + if (validateTime(timeTagRelativeText, TimeTypes.RELATIVE)) { + const { isNegative, days, hours, minutes, seconds, milliseconds } = getDurationTimeComponents( + parseDurationString(timeTagRelativeText, 'seconds'), + ); + tag = `${isNegative}${days}${hours}:${minutes}:${seconds}${milliseconds}`; return { tag, type: 'COMMAND_RELATIVE' }; } - match = testTime(timeTagRelativeText, RELATIVE_SIMPLE); - if (match) { - RELATIVE_SIMPLE.lastIndex = 0; - const [, second, ms] = match; - tag = `${second ? secondsToHMS(Number(second)) : ''}${ms ? ms : ''}`; + + if (validateTime(timeTagRelativeText, TimeTypes.RELATIVE_SIMPLE)) { + tag = getBalancedDuration(timeTagRelativeText); + if (parseDurationString(tag).milliseconds === 0) { + tag = tag.slice(0, -4); + } return { tag, type: 'COMMAND_RELATIVE' }; } } return { tag, type: 'ABSOLUTE' }; } -function secondsToHMS(seconds: number): string { - if (typeof seconds !== 'number' || isNaN(seconds)) { - throw new Error(`Expected a valid number for seconds, got ${seconds}`); - } - - const hours: number = Math.floor(seconds / 3600); - const minutes: number = Math.floor((seconds % 3600) / 60); - const remainingSeconds: number = seconds % 60; - - const hoursString = hours.toString().padStart(2, '0'); - const minutesString = minutes.toString().padStart(2, '0'); - const remainingSecondsString = remainingSeconds.toString().padStart(2, '0'); - - return `${hoursString}:${minutesString}:${remainingSecondsString}`; -} - // min length of one type VariableDeclarationArray = [VariableDeclaration, ...VariableDeclaration[]]; diff --git a/src/utilities/time.test.ts b/src/utilities/time.test.ts index 868ef6b09b..735c2d2b42 100644 --- a/src/utilities/time.test.ts +++ b/src/utilities/time.test.ts @@ -4,6 +4,7 @@ import { convertDurationStringToInterval, convertDurationStringToUs, convertUsToDurationString, + getBalancedDuration, getDaysInMonth, getDaysInYear, getDoy, @@ -12,16 +13,26 @@ import { getShortISOForDate, getTimeAgo, getUnixEpochTime, + isTimeBalanced, + isTimeMax, parseDoyOrYmdTime, + parseDurationString, + validateTime, } from '../../src/utilities/time'; +import { TimeTypes } from '../enums/time'; test('convertDurationStringToUs', () => { expect(convertDurationStringToUs('2y 318d 6h 16m 19s 200ms 0us')).toEqual(90577779200000); expect(convertDurationStringToUs('100ms -1000us')).toEqual(99000); expect(convertDurationStringToUs('200ms 0us')).toEqual(200000); expect(convertDurationStringToUs('30s')).toEqual(3e7); + expect(convertDurationStringToUs('300')).toEqual(300); - expect(() => convertDurationStringToUs('30f')).toThrowError('Must be of format: 1y 3d 2h 24m 35s 18ms 70us'); + expect(() => convertDurationStringToUs('30f')).toThrowError(`Invalid time format: Must be of format: + 1y 3d 2h 24m 35s 18ms 70us, + [+/-]DOYThh:mm:ss[.sss], + duration + `); }); test('convertDurationStringToInterval', () => { @@ -32,7 +43,11 @@ test('convertDurationStringToInterval', () => { expect(convertDurationStringToInterval('1d -5h')).toEqual('19 hours'); expect(convertDurationStringToInterval('- 5h 23m 0s 300ms')).toEqual('-5 hours -23 minutes -300 milliseconds'); - expect(() => convertDurationStringToUs('30f')).toThrowError('Must be of format: 1y 3d 2h 24m 35s 18ms 70us'); + expect(() => convertDurationStringToUs('30f')).toThrowError(`Invalid time format: Must be of format: + 1y 3d 2h 24m 35s 18ms 70us, + [+/-]DOYThh:mm:ss[.sss], + duration + `); }); test('convertUsToDurationString', () => { @@ -126,6 +141,28 @@ test('parseDoyOrYmdTime', () => { year: 2022, }); + expect(parseDoyOrYmdTime('012T03:01:30.920')).toEqual({ + days: 12, + hours: 3, + isNegative: false, + microseconds: 0, + milliseconds: 920, + minutes: 1, + seconds: 30, + years: 0, + }); + + expect(parseDoyOrYmdTime('-112T13:41:00')).toEqual({ + days: 112, + hours: 13, + isNegative: true, + microseconds: 0, + milliseconds: 0, + minutes: 41, + seconds: 0, + years: 0, + }); + expect(parseDoyOrYmdTime('2019-365T08:80:00.1234')).toEqual(null); expect(parseDoyOrYmdTime('2022-20-2T00:00:00')).toEqual(null); }); @@ -148,3 +185,160 @@ test('getTimeAgo', () => { test('getShortISOForDate', () => { expect(getShortISOForDate(new Date('2023-05-23T00:00:00.000Z'))).toEqual('2023-05-23T00:00:00'); }); + +test('parseDurationString', () => { + expect(parseDurationString('1h 30m 45s')).toEqual({ + days: 0, + hours: 1, + isNegative: false, + microseconds: 0, + milliseconds: 0, + minutes: 30, + seconds: 45, + years: 0, + }); + + expect(parseDurationString('-2d 12h 30m 15.5s 250ms')).toEqual({ + days: 2, + hours: 12, + isNegative: true, + microseconds: 0, + milliseconds: 250, + minutes: 30, + seconds: 15.5, + years: 0, + }); + + expect(parseDurationString('500us')).toEqual({ + days: 0, + hours: 0, + isNegative: false, + microseconds: 500, + milliseconds: 0, + minutes: 0, + seconds: 0, + years: 0, + }); + + expect(parseDurationString('1000')).toEqual({ + days: 0, + hours: 0, + isNegative: false, + microseconds: 1000, + milliseconds: 0, + minutes: 0, + seconds: 0, + years: 0, + }); + + expect(parseDurationString('-1000', 'seconds')).toEqual({ + days: 0, + hours: 0, + isNegative: true, + microseconds: 0, + milliseconds: 0, + minutes: 16, + seconds: 40, + years: 0, + }); + + expect(parseDurationString('+100000000000')).toEqual({ + days: 0, + hours: 0, + isNegative: false, + microseconds: 0, + milliseconds: 0, + minutes: 1, + seconds: 40, + years: 0, + }); + + expect(parseDurationString('-366', 'days')).toEqual({ + days: 1, + hours: 0, + isNegative: true, + microseconds: 0, + milliseconds: 0, + minutes: 0, + seconds: 0, + years: 1, + }); + + expect(parseDurationString('1.1', 'days')).toEqual({ + days: 1, + hours: 0.1, + isNegative: false, + microseconds: 0, + milliseconds: 0, + minutes: 0, + seconds: 0, + years: 0, + }); + + expect(parseDurationString('1.01', 'minutes')).toEqual({ + days: 0, + hours: 0, + isNegative: false, + microseconds: 0, + milliseconds: 0, + minutes: 1, + seconds: 0.01, + years: 0, + }); +}); + +test('isTimeBalanced', () => { + expect(isTimeBalanced('2024-001T00:00:00', TimeTypes.ABSOLUTE)).toBe(true); + expect(isTimeBalanced('2024-001T12:90:00', TimeTypes.ABSOLUTE)).toBe(false); + expect(isTimeBalanced('9999-365T23:59:60.999', TimeTypes.ABSOLUTE)).toBe(false); + expect(isTimeBalanced('2024-365T23:59:60', TimeTypes.ABSOLUTE)).toBe(false); + expect(isTimeBalanced('2023-363T23:19:30', TimeTypes.ABSOLUTE)).toBe(true); + expect(isTimeBalanced('0000-000T00:00:00', TimeTypes.ABSOLUTE)).toBe(false); + expect(isTimeBalanced('0000-000T24:60:60', TimeTypes.ABSOLUTE)).toBe(false); + expect(isTimeBalanced('001T12:43:20.000', TimeTypes.RELATIVE)).toBe(true); + expect(isTimeBalanced('09:04:00.340', TimeTypes.RELATIVE)).toBe(true); + expect(isTimeBalanced('001T23:59:60.000', TimeTypes.RELATIVE)).toBe(false); + expect(isTimeBalanced('365T23:59:60.000', TimeTypes.RELATIVE)).toBe(false); + expect(isTimeBalanced('24:60:60', TimeTypes.RELATIVE)).toBe(false); + expect(isTimeBalanced('+001T12:43:20.000', TimeTypes.EPOCH)).toBe(true); + expect(isTimeBalanced('-09:04:00.340', TimeTypes.EPOCH)).toBe(true); + expect(isTimeBalanced('-001T23:59:60.000', TimeTypes.EPOCH)).toBe(false); + expect(isTimeBalanced('-365T23:59:60.000', TimeTypes.EPOCH)).toBe(false); + expect(isTimeBalanced('365T22:59:60.000', TimeTypes.EPOCH)).toBe(false); +}); + +test('getBalancedDuration', () => { + expect(getBalancedDuration('001T23:59:60.1')).toBe('002T00:00:00.100'); + expect(getBalancedDuration('24:60:60')).toBe('001T01:01:00.000'); + expect(getBalancedDuration('1')).toBe('00:00:01.000'); + expect(getBalancedDuration('45600')).toBe('12:40:00.000'); + expect(getBalancedDuration('-001T00:59:10.000')).toBe('-001T00:59:10.000'); + expect(getBalancedDuration('-365T22:59:60.999')).toBe('-365T23:00:00.999'); + expect(getBalancedDuration('-1')).toBe('-00:00:01.000'); + expect(getBalancedDuration('+190')).toBe('00:03:10.000'); +}); + +test('isTimeMax', () => { + expect(isTimeMax('9999-365T23:59:60.999', TimeTypes.ABSOLUTE)).toBe(true); + expect(isTimeMax('365T23:59:60.999', TimeTypes.RELATIVE)).toBe(true); + expect(isTimeMax('365T23:59:60.000', TimeTypes.RELATIVE)).toBe(true); + expect(isTimeMax('-365T23:59:60.999', TimeTypes.EPOCH)).toBe(true); + expect(isTimeMax('365T22:59:60.999', TimeTypes.EPOCH)).toBe(false); +}); + +test('validateTime', () => { + expect(validateTime('2024-001T00:00:00', TimeTypes.ABSOLUTE)).toBe(true); + expect(validateTime('2024-001T', TimeTypes.ABSOLUTE)).toBe(false); + expect(validateTime('12:90:00', TimeTypes.ABSOLUTE)).toBe(false); + expect(validateTime('-001T23:59:60.000', TimeTypes.RELATIVE)).toBe(false); + expect(validateTime('365T23:59:60.000', TimeTypes.RELATIVE)).toBe(true); + expect(validateTime('-03:59:60.000', TimeTypes.RELATIVE)).toBe(false); + expect(validateTime('+03:59:60.000', TimeTypes.RELATIVE)).toBe(false); + expect(validateTime('03:59:60.190', TimeTypes.RELATIVE)).toBe(true); + expect(validateTime('2023-365T23:59:60', TimeTypes.EPOCH)).toBe(false); + expect(validateTime('2023-365T23:59:60', TimeTypes.EPOCH)).toBe(false); + expect(validateTime('-001T23:59:60.000', TimeTypes.EPOCH)).toBe(true); + expect(validateTime('365T23:59:60.000', TimeTypes.EPOCH)).toBe(true); + expect(validateTime('+03:59:60.000', TimeTypes.EPOCH)).toBe(true); + expect(validateTime('3:59:60', TimeTypes.EPOCH)).toBe(false); +}); diff --git a/src/utilities/time.ts b/src/utilities/time.ts index c3071064ff..abb9688ff6 100644 --- a/src/utilities/time.ts +++ b/src/utilities/time.ts @@ -1,12 +1,175 @@ import { padStart } from 'lodash-es'; import parseInterval from 'postgres-interval'; +import { TimeTypes } from '../enums/time'; import type { ActivityDirectiveId, ActivityDirectivesMap } from '../types/activity'; import type { SpanUtilityMaps, SpansMap } from '../types/simulation'; -import type { ParsedDoyString, ParsedDurationString, ParsedYmdString } from '../types/time'; +import type { DurationTimeComponents, ParsedDoyString, ParsedDurationString, ParsedYmdString } from '../types/time'; -function parseDurationString(durationString: string): ParsedDurationString | never { - const validNegationRegex = `((?-)\\s)?`; - const validDurationValueRegex = `-?\\d+(?:\\.\\d+)?`; +const ABSOLUTE_TIME = /^(\d{4})-(\d{3})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?$/; +const RELATIVE_TIME = + /^(?([0-9]{3}))?(T)?(?
([0-9]{2})):(?([0-9]{2})):(?[0-9]{2})?(\.)?(?([0-9]+))?$/; +const RELATIVE_SIMPLE = /(\d+)(\.[0-9]+)?$/; +const EPOCH_TIME = + /^((?[+-]?))(?([0-9]{3}))?(T)?(?
([0-9]{2})):(?([0-9]{2})):(?[0-9]{2})?(\.)?(?([0-9]+))?$/; +const EPOCH_SIMPLE = /(^[+-]?)(\d+)(\.[0-9]+)?$/; + +/** + * Validates a time string based on the specified type. + * @param {string} time - The time string to validate. + * @param {TimeTypes} type - The type of time to validate against. + * @returns {boolean} - True if the time string is valid, false otherwise. + * @example + * validateTime('2022-012T12:34:56.789', TimeTypes.ABSOLUTE); // true + */ +export function validateTime(time: string, type: TimeTypes): boolean { + switch (type) { + case TimeTypes.ABSOLUTE: + return ABSOLUTE_TIME.exec(time) !== null; + case TimeTypes.EPOCH: + return EPOCH_TIME.exec(time) !== null; + case TimeTypes.RELATIVE: + return RELATIVE_TIME.exec(time) !== null; + case TimeTypes.EPOCH_SIMPLE: + return EPOCH_SIMPLE.exec(time) !== null; + case TimeTypes.RELATIVE_SIMPLE: + return RELATIVE_SIMPLE.exec(time) !== null; + default: + return false; + } +} + +/** + * Determines if the given time string is a max time based on the specified time type. + * @param {string} time - The time string to check. + * @param {TimeTypes} type - The time type to check against. + * @returns {boolean} - True if the time string is a max time, false otherwise. + * @example + * isTimeMax('2099-365T23:59:59.999', TimeTypes.ABSOLUTE); // false + */ +export function isTimeMax(time: string, type: TimeTypes): boolean { + switch (type) { + case TimeTypes.ABSOLUTE: { + const year = (parseDoyOrYmdTime(getDoyTime(new Date(getUnixEpochTime(time)))) as ParsedDoyString)?.year; + return year ? year > 9999 : true; + } + case TimeTypes.EPOCH: + case TimeTypes.RELATIVE: { + const duration = parseDurationString(time); + const originalYear = parseInt(convertDurationToDoy(duration).slice(0, 4)); + const year = ( + parseDoyOrYmdTime(getDoyTime(new Date(getUnixEpochTime(convertDurationToDoy(duration))))) as ParsedDoyString + )?.year; + return originalYear !== year; + } + default: + return false; + } +} + +/** + * Determines if the given time string is balanced based on the specified time type. + * @param {string} time - The time string to check. + * @param {TimeTypes} type - The time type to check against. + * @returns {boolean} - True if the time string is balanced, false otherwise. + * @example + * isTimeBalanced('2022-01-01T00:00:00.000', TimeTypes.ABSOLUTE); // true + * isTimeBalanced('50000d', TimeTypes.RELATIVE); // false + */ +export function isTimeBalanced(time: string, type: TimeTypes): boolean { + switch (type) { + case TimeTypes.ABSOLUTE: { + const balancedTime = parseDoyOrYmdTime(getDoyTime(new Date(getUnixEpochTime(time)))) as ParsedDoyString; + const originalTime = parseDoyOrYmdTime(time) as ParsedDoyString; + if (balancedTime === null || originalTime === null) { + return false; + } + return originalTime.year === balancedTime.year; + } + case TimeTypes.EPOCH: + case TimeTypes.RELATIVE: { + const originalTime = parseDurationString(time); + const balancedTime = parseDurationString(getBalancedDuration(time)); + + if (balancedTime === null || originalTime === null) { + return false; + } + return ( + balancedTime.days === originalTime.days && + balancedTime.hours === originalTime.hours && + balancedTime.minutes === originalTime.minutes && + balancedTime.seconds === originalTime.seconds && + balancedTime.milliseconds === originalTime.milliseconds + ); + } + default: + return false; + } +} + +/** + * Parse a duration string into a parsed duration object. + * If no unit is specified, it defaults to microseconds. + * + * @example + * parseDurationString('1h 30m'); + * // => { + * // => days: 0, + * // => hours: 1, + * // => isNegative: false, + * // => microseconds: 0, + * // => milliseconds: 0, + * // => minutes: 30, + * // => seconds: 0, + * // => years: 0, + * // => } + * @example + * parseDurationString('-002T00:45:00.010') + * // => { + * // => days: 2, + * // => hours: 0, + * // => isNegative: true, + * // => microseconds: 0, + * // => milliseconds: 10, + * // => minutes: 45, + * // => seconds: 0, + * // => years: 0, + * // => } + * @example + * parseDurationString('90') + * // => { + * // => minutes: 0, + * // => seconds: 0, + * // => microseconds: 90, + * // => milliseconds: 0, + * // => days: 0, + * // => hours: 0, + * // => isNegative: false, + * // => years: 0, + * // => } + * @example + * parseDurationString('-123.456s', 'microseconds') + * // => { + * // => microseconds: 0, + * // => milliseconds: 456, + * // => seconds: -123, + * // => minutes: 0, + * // => hours: 0, + * // => days: 0, + * // => isNegative: true, + * // => years: 0, + * // => } + * + * @param {string} durationString - The duration string to parse. + * @param {'years' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' | 'microseconds'} units - The units to parse the duration string in. + * @return {ParsedDurationString} The parsed duration object. + * @throws {Error} If the duration string is invalid. + */ +export function parseDurationString( + durationString: string, + units: 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' | 'microseconds' = 'microseconds', +): ParsedDurationString | never { + const validNegationRegex = `((?-))?`; + const validDurationValueRegex = `([+-]?)(\\d+)(\\.\\d+)?`; const validYearsDurationRegex = `(?:\\s*(?${validDurationValueRegex})y)`; const validDaysDurationRegex = `(?:\\s*(?${validDurationValueRegex})d)`; const validHoursDurationRegex = `(?:\\s*(?${validDurationValueRegex})h)`; @@ -19,7 +182,7 @@ function parseDurationString(durationString: string): ParsedDurationString | nev `^${validNegationRegex}${validYearsDurationRegex}?${validDaysDurationRegex}?${validHoursDurationRegex}?${validMinutesDurationRegex}?${validSecondsDurationRegex}?${validMillisecondsDurationRegex}?${validMicrosecondsDurationRegex}?$`, ); - const matches = durationString.match(fullValidDurationRegex); + let matches = durationString.match(fullValidDurationRegex); if (matches !== null) { const { @@ -47,23 +210,151 @@ function parseDurationString(durationString: string): ParsedDurationString | nev }; } - const fullMicrosecondsRegex = new RegExp(`^${validDurationValueRegex}$`); + const durationTime = parseDoyOrYmdTime(durationString) as ParsedDurationString; + if (durationTime) { + return durationTime; + } + + matches = new RegExp(`^(?([+-]?))(?(\\d+))(?\\.(\\d+))?$`).exec(durationString); + if (matches !== null) { + const { groups: { sign = '', int = '0', decimal = '0' } = {} } = matches; + let microsecond = 0; + let millisecond = 0; + let second = 0; + let minute = 0; + let hour = 0; + let day = 0; + let year = 0; + + const number = parseInt(int); + const decimalNum = decimal ? parseFloat(decimal) : 0; + + //shift everthing based on units + switch (units) { + case 'microseconds': + microsecond = number; + break; + case 'milliseconds': + microsecond = decimalNum; + millisecond = number; + break; + case 'seconds': + millisecond = decimalNum; + second = number; + break; + case 'minutes': + second = decimalNum; + minute = number; + break; + case 'hours': + minute = decimalNum; + hour = number; + break; + case 'days': + hour = decimalNum; + day = number; + break; + } + + // Normalize microseconds + millisecond += Math.floor(microsecond / 1000000); + microsecond = microsecond % 1000000; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + // Normalize milliseconds and seconds + second += Math.floor(millisecond / 1000); + millisecond = millisecond % 1000; + + // Normalize seconds and minutes + minute += Math.floor(second / 60); + second = second % 60; + + // Normalize minutes and hours + hour += Math.floor(minute / 60); + minute = minute % 60; + + // Normalize hours and days + day += Math.floor(hour / 24); + hour = hour % 24; + + // Normlize days and years + year += Math.floor(day / 365); + day = day % 365; - if (fullMicrosecondsRegex.test(durationString)) { - const microseconds = parseFloat(durationString); return { - days: 0, - hours: 0, - isNegative: microseconds < 0, - microseconds, - milliseconds: 0, - minutes: 0, - seconds: 0, - years: 0, + days: day, + hours: hour, + isNegative: sign !== '' && sign !== '+', + microseconds: microsecond, + milliseconds: millisecond, + minutes: minute, + seconds: second, + years: year, }; } - throw Error('Must be of format: 1y 3d 2h 24m 35s 18ms 70us'); + throw new Error(`Invalid time format: Must be of format: + 1y 3d 2h 24m 35s 18ms 70us, + [+/-]DOYThh:mm:ss[.sss], + duration + `); +} + +/** + * Format a duration object to a day of year string. + * + * @example + * convertDurationToDoy({ + * years: 0, + * days: 1, + * hours: 0, + * minutes: 45, + * seconds: 0, + * milliseconds: 10, + * microseconds: 0, + * }) + * + * result: '1970-1T00:45:00.010' + * + * @param {ParsedDurationString} duration - The duration object to format. + * @returns {string} - The formatted day of year string. + */ +function convertDurationToDoy(duration: ParsedDurationString): string { + const years = duration.years === 0 ? '1970' : String(duration.years).padStart(4, '0'); + const day = Math.max(1, Math.floor(duration.days)); + const hours = String(duration.hours).padStart(2, '0'); + const minutes = String(duration.minutes).padStart(2, '0'); + const seconds = String(duration.seconds).padStart(2, '0'); + const milliseconds = String(duration.milliseconds * 1000).padStart(3, '0'); + + return `${years}-${day.toString().padStart(3, '0')}T${hours}:${minutes}:${seconds}.${milliseconds}`; +} + +/** + * Gets the balanced duration based on the given time string. + * + * @example + * getBalancedDuration('-002T00:60:00.010') + * // => '-002T01:00:00.010' + * + * @param {string} time - The time string to calculate the balanced duration from. + * @returns {string} The balanced duration string. + */ +export function getBalancedDuration(time: string): string { + const duration = parseDurationString(time, 'seconds'); + const balancedTime = getDoyTime(new Date(getUnixEpochTime(convertDurationToDoy(duration)))); + const parsedBalancedTime = parseDoyOrYmdTime(balancedTime) as ParsedDoyString; + const shouldIncludeDay = duration.days > 0 || parsedBalancedTime.doy > 1; + + const sign = duration.isNegative ? '-' : ''; + const day = shouldIncludeDay + ? `${String(parsedBalancedTime.doy - (duration.days > 0 ? 0 : 1)).padStart(3, '0')}T` + : ''; + const hour = String(parsedBalancedTime.hour).padStart(2, '0'); + const minutes = String(parsedBalancedTime.min).padStart(2, '0'); + const seconds = String(parsedBalancedTime.sec).padStart(2, '0'); + const milliseconds = String(parsedBalancedTime.ms).padStart(3, '0'); + return `${sign}${day}${hour}:${minutes}:${seconds}.${milliseconds}`; } function addUnit(value: number, unit: string, isNegative: boolean) { @@ -344,6 +635,24 @@ export function getDoyTimeComponents(date: Date) { }; } +/** + * Get the time components for a given duration object. + * @example getDurationTimeComponents({ years: 2, days: 3, hours: 10, minutes: 30, seconds: 45, milliseconds: 0, microseconds: 0, isNegative: false }) + * -> { days: '003', hours: '10', isNegative: '', microseconds: '', milliseconds: '000', minutes: '30', seconds: '45', years: '0002' } + */ +export function getDurationTimeComponents(duration: ParsedDurationString): DurationTimeComponents { + return { + days: duration.days !== 0 ? String(duration.days).padStart(3, '0') : '', + hours: duration.hours.toString().padStart(2, '0'), + isNegative: duration.isNegative ? '-' : '', + microseconds: duration.microseconds !== 0 ? String(duration.microseconds).padStart(3, '0') : '', + milliseconds: duration.milliseconds !== 0 ? String(duration.milliseconds).padStart(3, '0') : '', + minutes: duration.minutes.toString().padStart(2, '0'), + seconds: duration.seconds.toString().padStart(2, '0'), + years: duration.years.toString().padStart(4, '0'), + }; +} + /** * Get a day-of-year timestamp from a given JavaScript Date object. * @example getDoyTime(new Date(1577779200000)) -> 2019-365T08:00:00.000 @@ -455,7 +764,10 @@ export function getUnixEpochTimeFromInterval(startTime: string, interval: string /** * Parses a date string (YYYY-MM-DDTHH:mm:ss) or DOY string (YYYY-DDDDTHH:mm:ss) into its separate components */ -export function parseDoyOrYmdTime(dateString: string, numDecimals = 6): null | ParsedDoyString | ParsedYmdString { +export function parseDoyOrYmdTime( + dateString: string, + numDecimals = 6, +): null | ParsedDoyString | ParsedYmdString | ParsedDurationString { const matches = (dateString ?? '').match( new RegExp( `^(?\\d{4})-(?:(?(?:[0]?[0-9])|(?:[1][1-2]))-(?(?:[0-2]?[0-9])|(?:[3][0-1]))|(?\\d{1,3}))(?:T(?