From 6800456ceee771676da1dd4c72bf062887bce0f9 Mon Sep 17 00:00:00 2001 From: pjanik Date: Tue, 16 Jul 2024 20:26:09 +0200 Subject: [PATCH 01/10] feat: add case table dates support [PT-187965959] --- v3/src/components/case-table/use-columns.tsx | 20 + v3/src/models/data/attribute.ts | 12 + v3/src/utilities/data-utils.ts | 4 + v3/src/utilities/date-parser.test.ts | 80 ++++ v3/src/utilities/date-parser.ts | 286 ++++++++++++ v3/src/utilities/date-utils.ts | 462 ++++--------------- v3/src/utilities/translation/translate.ts | 4 + 7 files changed, 498 insertions(+), 370 deletions(-) create mode 100644 v3/src/utilities/date-parser.test.ts create mode 100644 v3/src/utilities/date-parser.ts diff --git a/v3/src/components/case-table/use-columns.tsx b/v3/src/components/case-table/use-columns.tsx index 2436cf6795..a82fa63380 100644 --- a/v3/src/components/case-table/use-columns.tsx +++ b/v3/src/components/case-table/use-columns.tsx @@ -15,6 +15,8 @@ import { kDefaultColumnWidth, symDom, TColumn, TRenderCellProps } from "./case-t import CellTextEditor from "./cell-text-editor" import ColorCellTextEditor from "./color-cell-text-editor" import { ColumnHeader } from "./column-header" +import { parseDate } from "../../utilities/date-parser" +import { DatePrecision, formatDate } from "../../utilities/date-utils" // cache d3 number formatters so we don't have to generate them on every render type TNumberFormatter = (n: number) => string @@ -52,6 +54,24 @@ export function renderValue(str = "", num = NaN, attr?: IAttribute, key?: number if (formatter) str = formatter(num) } + // dates + // Note that CODAP v2 formats dates in the case table ONLY if the user explicitly specified type as "date". + // Dates are not interpreted as dates and formatted by default. + if (userType === "date" && str !== "") { + const date = parseDate(str, true) + if (date) { + // TODO: add precision support for date formatting + const formattedDate = formatDate(date, DatePrecision.None) + return { + value: str, + content: {formattedDate || str} + } + } else { + // If the date is not valid, wrap it in quotes (CODAP V2 behavior). + str = `"${str}"` + } + } + return { value: str, content: {str} diff --git a/v3/src/models/data/attribute.ts b/v3/src/models/data/attribute.ts index 4795261f4e..e26893a947 100644 --- a/v3/src/models/data/attribute.ts +++ b/v3/src/models/data/attribute.ts @@ -33,6 +33,7 @@ import { Formula, IFormula } from "../formula/formula" import { applyModelChange } from "../history/apply-model-change" import { withoutUndo } from "../history/without-undo" import { V2Model } from "./v2-model" +import { isDateString } from "../../utilities/date-parser" export const kDefaultFormatStr = ".3~f" @@ -129,6 +130,13 @@ export const Attribute = V2Model.named("Attribute").props({ self.changeCount // eslint-disable-line no-unused-expressions return self.strValues.reduce((prev, current) => parseColor(current) ? ++prev : prev, 0) }), + getDateCount: cachedFnFactory(() => { + // Note that `self.changeCount` is absolutely not necessary here. However, historically, this function used to be + // a MobX computed property, and `self.changeCount` was used to invalidate the cache. Also, there are tests + // (and possibly some features?) that depend on MobX reactivity. Hence, this is left here for now. + self.changeCount // eslint-disable-line no-unused-expressions + return self.strValues.reduce((prev, current) => isDateString(current) ? ++prev : prev, 0) + }), get hasFormula() { return !!self.formula && !self.formula.empty }, @@ -215,6 +223,10 @@ export const Attribute = V2Model.named("Attribute").props({ const numCount = self.getNumericCount() if (numCount > 0 && numCount === this.length - self.getEmptyCount()) return "numeric" + // only infer date if all non-empty values are dates + const dateCount = self.getDateCount() + if (dateCount > 0 && dateCount === this.length - self.getEmptyCount()) return "date" + return "categorical" }, get format() { diff --git a/v3/src/utilities/data-utils.ts b/v3/src/utilities/data-utils.ts index 292832ba88..d597782dd4 100644 --- a/v3/src/utilities/data-utils.ts +++ b/v3/src/utilities/data-utils.ts @@ -42,3 +42,7 @@ export const numericSortComparator = function ({a, b, order}: ICompareProps): nu if (aIsNaN) return order === "asc" ? -1 : 1 return order === "asc" ? a - b : b - a } + +export const isNumeric = function (value: any): value is number { + return value != null && value !== "" && !isNaN(value) +} diff --git a/v3/src/utilities/date-parser.test.ts b/v3/src/utilities/date-parser.test.ts new file mode 100644 index 0000000000..116cefeba2 --- /dev/null +++ b/v3/src/utilities/date-parser.test.ts @@ -0,0 +1,80 @@ +import { isDateString, parseDate } from './date-parser' + +describe('Date Parser tests - V2 compatibility', () => { + // These tests are ported from V2 and should always pass unchanged as long as we want to maintain compatibility. + test('local date format', () => { + expect(parseDate('11/9/2016', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString()) + expect(parseDate('11/09/2016', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString()) + expect(parseDate('4/1/1928', true)?.toISOString()).toBe(new Date(1928, 3, 1).toISOString()) + expect(parseDate('11/9/16', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString()) + expect(parseDate('11/09/16', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString()) + expect(parseDate('04/1/28', true)?.toISOString()).toBe(new Date(2028, 3, 1).toISOString()) + }) +test('ISO dates', () => { + expect(parseDate('2016-01', true)?.toISOString()).toBe(new Date(2016, 0, 1).toISOString()) + expect(parseDate('2016-02-02', true)?.toISOString()).toBe(new Date(2016, 1, 2).toISOString()) + }) +test('day, month name, year dates', () => { + expect(parseDate('03 Mar 2016', true)?.toISOString()).toBe(new Date(2016, 2, 3).toISOString()) + }) +test('traditional US dates', () => { + expect(parseDate('April 4, 2016', true)?.toISOString()).toBe(new Date(2016, 3, 4).toISOString()) + expect(parseDate('Apr 5, 2016', true)?.toISOString()).toBe(new Date(2016, 3, 5).toISOString()) + expect(parseDate('Monday, May 5, 2016', true)?.toISOString()).toBe(new Date(2016, 4, 5).toISOString()) + }) +test('year.month.day dates', () => { + expect(parseDate('2016.6.6', true)?.toISOString()).toBe(new Date(2016, 5, 6).toISOString()) + }) +test('unix dates', () => { + expect(parseDate('Thu Jul 11 09:12:47 PDT 2019', true)?.toISOString()).toBe(new Date(2019, 6, 11, 9, 12, 47).toISOString()) + }) +test('UTC dates', () => { + expect(parseDate('Thu, 11 Jul 2019 16:17:01 GMT', true)?.toISOString()).toBe(new Date(2019, 6, 11, 16, 17, 1).toISOString()) + }) +test('ISO Date/time', () => { + expect(parseDate('2019-07-11T16:20:38.575Z', true)?.toISOString()).toBe(new Date(2019, 6, 11, 16, 20, 38, 575).toISOString()) + }) +test('dates with times', () => { + expect(parseDate('11/9/2016 7:18', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString()) + expect(parseDate('11/9/2016 7:18am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString()) + expect(parseDate('11/9/2016 7:18 am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString()) + expect(parseDate('11/9/2016 7:18AM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString()) + expect(parseDate('11/9/2016 7:18:02', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2).toISOString()) + expect(parseDate('11/9/2016 7:18:02 AM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2).toISOString()) + expect(parseDate('11/9/2016 7:18:02PM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 19, 18, 2).toISOString()) + expect(parseDate('11/9/2016 7:18:02.123', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2, 123).toISOString()) + expect(parseDate('11/9/2016 7:18:02.234 pm', true)?.toISOString()).toBe(new Date(2016, 10, 9, 19, 18, 2, 234).toISOString()) + expect(parseDate('11/9/2016 7:18:02.234am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2, 234).toISOString()) + expect(parseDate('11/9/2016 17:18:02', true)?.toISOString()).toBe(new Date(2016, 10, 9, 17, 18, 2).toISOString()) + expect(parseDate('11/9/2016 17:18:02.123', true)?.toISOString()).toBe(new Date(2016, 10, 9, 17, 18, 2, 123).toISOString()) + }) +test('ISO 8601', () => { + expect(isDateString('2016-11-10')).toBe(true) + expect(isDateString('2016-11-09T07:18:02')).toBe(true) + expect(isDateString('2016-11-09T07:18:02-07:00')).toBe(true) + expect(isDateString('2016-11-09T07:18:02+0700')).toBe(true) + expect(isDateString('2016-11-10T21:27:42Z')).toBe(true) + expect(isDateString('2016-11-09T07:18:02.123')).toBe(true) + expect(isDateString('2016-11-09T07:18:02.123+07:00')).toBe(true) + expect(isDateString('2016-11-09T07:18:02.123-0700')).toBe(true) + expect(isDateString('2016-11-10T21:27:42.123Z')).toBe(true) + expect(isDateString('September 1, 2016')).toBe(true) + expect(isDateString('2016-11-10T21:27:42.12Z')).toBe(true) + }) +test('invalid strings', () => { + expect(isDateString('')).toBe(false) + expect(isDateString('a')).toBe(false) + expect(isDateString('123')).toBe(false) + expect(isDateString('123%')).toBe(false) + expect(isDateString('//')).toBe(false) + expect(isDateString(':')).toBe(false) + expect(isDateString('::')).toBe(false) + // Likely meant to be dates, but not recognized, yet. + expect(isDateString('11/ 9/2016')).toBe(false) + expect(isDateString('12/31')).toBe(false) + expect(isDateString('1/2')).toBe(false) + expect(isDateString('2016-1-10T21:27:42.123Z')).toBe(false) + expect(isDateString('2016-1-10T21:7:42.123Z')).toBe(false) + expect(isDateString('2016-1-10T1:07:42.123Z')).toBe(false) + }) +}) diff --git a/v3/src/utilities/date-parser.ts b/v3/src/utilities/date-parser.ts new file mode 100644 index 0000000000..6c110e13d9 --- /dev/null +++ b/v3/src/utilities/date-parser.ts @@ -0,0 +1,286 @@ +import { t } from "./translation/translate" + +/** + * parseDate - Parses dates in a uniform manner across browser + * + * Has two modes: strict (default) and loose + * + * In strict mode recognizes year, month or year, month, day iso calendar date + * or date/time strings (not just year) and local dates and date/time strings. + * In loose mode recognizes all iso calendar dates and iso date-time strings and + * a constiety of date and date/time formats. + * + * Recognized in strict mode or loose mode, en/US locale: + * * 2019-05 + * * 2019-05-25 + * * 2019-05-25 10:04Z + * * 2019-05-25T10:04Z + * * 2019-05-25T10:04:03+01:00 + * * 2019-05-25T10:04:03.123+01:00 + * * 5/25/2019 + * * 5/25/2019 10:04 + * * 5/25/2019 10:04am + * * 5/25/2019 10:04:32.123 AM + * + * Recognized in loose mode + * * 2019 + * * 2019.05.25 + * * 25 Oct 2019 + * * Oct 25, 2019 + * * Oct. 25, 2019 + * * October 25, 2019 + * Not recognized as dates + * * relative dates (e.g. today, tomorrow, next week) + * * anniversary dates (e.g. June 5) + * * out of range dates (e.g. June 32, 2019) + */ + +type GroupMap = Record + +type DateSpec = { + year: number + month: number + day: number + hour: number + min: number + sec: number + subsec: number +} + +const timePart = '(\\d\\d?)(?::(\\d\\d?)(?::(\\d\\d)(?:\\.(\\d+))?)?)?' + +const monthsFull = [ + 'DG.Formula.DateLongMonthJanuary', + 'DG.Formula.DateLongMonthFebruary', + 'DG.Formula.DateLongMonthMarch', + 'DG.Formula.DateLongMonthApril', + 'DG.Formula.DateLongMonthMay', + 'DG.Formula.DateLongMonthJune', + 'DG.Formula.DateLongMonthJuly', + 'DG.Formula.DateLongMonthAugust', + 'DG.Formula.DateLongMonthSeptember', + 'DG.Formula.DateLongMonthOctober', + 'DG.Formula.DateLongMonthNovember', + 'DG.Formula.DateLongMonthDecember' +].map(function (m) { return t(m).toLowerCase() }) + +const monthsAbbr = [ + 'DG.Formula.DateShortMonthJanuary', + 'DG.Formula.DateShortMonthFebruary', + 'DG.Formula.DateShortMonthMarch', + 'DG.Formula.DateShortMonthApril', + 'DG.Formula.DateShortMonthMay', + 'DG.Formula.DateShortMonthJune', + 'DG.Formula.DateShortMonthJuly', + 'DG.Formula.DateShortMonthAugust', + 'DG.Formula.DateShortMonthSeptember', + 'DG.Formula.DateShortMonthOctober', + 'DG.Formula.DateShortMonthNovember', + 'DG.Formula.DateShortMonthDecember' +].map(function (m) { return t(m).toLowerCase() }) + +const daysOfWeek = [ + "DG.Formula.DateLongDaySunday", + "DG.Formula.DateLongDayMonday", + "DG.Formula.DateLongDayTuesday", + "DG.Formula.DateLongDayWednesday", + "DG.Formula.DateLongDayThursday", + "DG.Formula.DateLongDayFriday", + "DG.Formula.DateLongDaySaturday", +].map(function (dow) { return t(dow).toLowerCase() }) + +const daysOfWeekAbbr = [ + "DG.Formula.DateShortDaySunday", + "DG.Formula.DateShortDayMonday", + "DG.Formula.DateShortDayTuesday", + "DG.Formula.DateShortDayWednesday", + "DG.Formula.DateShortDayThursday", + "DG.Formula.DateShortDayFriday", + "DG.Formula.DateShortDaySaturday", +].map(function (dow) { return t(dow).toLowerCase() }) + +const monthsProperAbbrRE = monthsAbbr.map(function (str) { return `${str }\\.` }) +const monthsProperAbbr = monthsAbbr.map(function (str) { return `${str }.` }) +// const ordinals='0th,1st,2nd,3rd,4th,5th,6th,7th,8th,9th' +const monthsArray = monthsAbbr.concat(monthsProperAbbr, monthsFull) +const monthsArrayRE = monthsAbbr.concat(monthsProperAbbrRE, monthsFull) +const daysOfWeekArray = daysOfWeek.concat(daysOfWeekAbbr) + +// yyyy-MM-dd hh:mm:ss.SSSZ +const isoDateTimeRE = + // eslint-disable-next-line max-len + /^(\d{4})-([01]\d)(?:-([0-3]\d)(?:[T ]([0-2]\d)(?::([0-5]\d)(?::([0-5]\d)(?:[.,](\d+))?)?)?(Z|(?:[+-]\d\d:?\d\d?)| ?[a-zA-Z]{1,4}T)?)?)?$/ +const isoDateTimeGroupMap = { year: 1, month: 2, day: 3, hour: 4, min: 5, sec: 6, subsec: 7, timezone: 8 } + +// MM/dd/yyyy hh:mm:ss.SSS PM +// eslint-disable-next-line max-len +const localDateTimeRE = /^([01]?\d)\/([0-3]?\d)\/(\d{4}|\d{2})(?:,? (\d\d?)(?::(\d\d?)(?::(\d\d)(?:\.(\d+))?)?)?(?: ?(am|pm|AM|PM))?)?$/ +const localDateTimeGroupMap = { year: 3, month: 1, day: 2, hour: 4, min: 5, sec: 6, subsec: 7, ampm: 8, timezone: 9 } + +// dd MMM, yyyy or MMM, yyyy +// eslint-disable-next-line max-len +const dateVar1 = new RegExp(`^(\\d\\d?) (${ monthsArrayRE.join('|') }),? (\\d{4})(?: ${ timePart }(?: (am|pm))?)?$`, 'i') +const dateVar1GroupMap = { year: 3, month: 2, day: 1, hour: 4, min: 5, sec: 6, subsec: 7, ampm: 8 } + +// yyyy-mm-dd, yyyy.mm.dd, yyyy/mm/dd +// Require all three parts +const dateVar2 = new RegExp(`^(\\d{4})[./-](\\d\\d?)[./-](\\d\\d?)(?: ${ timePart }(?: (am|pm|AM|PM))?)?$`) +const dateVar2GroupMap = { year: 1, month: 2, day: 3, hour: 4, min: 5, sec: 6, subsec: 7, ampm: 8 } + +// MMM dd, yyyy or MMM yyyy +// eslint-disable-next-line max-len +const dateVar3 = new RegExp(`^(?:(?:${ daysOfWeekArray.join('|') }),? )?(${ monthsArrayRE.join('|') })(?: (\\d\\d?),)? (\\d{4})(?: ${ timePart }(?: (am|pm))?)?$`, 'i') +const dateVar3GroupMap = { year: 3, month: 1, day: 2, hour: 4, min: 5, sec: 6, subsec: 7, ampm: 8 } + +// 'hh:mm:ss AM/PM on dd/MM/yyyy' +const dateVar4 = /(\d\d?):(\d\d)(?::(\d\d))? (AM|PM) on (\d\d?)\/(\d\d?)\/(\d{4})/ +const dateVar4GroupMap = { year: 5, month: 6, day: 7, hour: 1, min: 2, sec: 3, ampm: 4 } + +// unix dates: Tue Jul 9 18:16:04 PDT 2019 +// eslint-disable-next-line max-len +const unixDate = new RegExp(`^(?:(?:${ daysOfWeekAbbr.join('|') }) )?(${ monthsAbbr.join('|') }) ([ \\d]\\d) ([ \\d]\\d):(\\d\\d):(\\d\\d) ([A-Z]{3}) (\\d{4})$`, 'i') +const unixDateGroupMap = { year: 7, month: 1, day: 2, hour: 3, min: 4, sec: 5, timezone: 6 } + +// new Date().toString(), most browsers +// eslint-disable-next-line max-len +const browserDate = new RegExp(`^(?:${ daysOfWeekAbbr.join('|') }) (${ monthsAbbr.join('|') }) (\\d\\d?),? (\\d{4})(?: ${ timePart } (GMT(?:[+-]\\d{4})?(?: \\([\\w ]+\\))?))`, 'i') +const browserDateGroupMap = { year: 3, month: 1, day: 2, hour: 4, min: 5, sec: 6, subsec: 7, timezone: 8 } + +// eslint-disable-next-line max-len +const utcDate = new RegExp(`^(?:${ daysOfWeekAbbr.join('|') }),? (\\d\\d?) (${ monthsAbbr.join('|') }) (\\d{4}) ${ timePart } GMT$`, 'i') +const utcDateGroupMap = { year: 3, month: 2, day: 1, hour: 4, min: 5, sec: 6, subsec: 7, timezone: 8 } + +// yyyy +const dateVarYearOnly = /^\d{4}$/ +const dateVarYearOnlyGroupMap = { year: 0 } + +// MMMM dd, yyyy hh:mm:ss.SSS PM + +const formatSpecs = [ + { strict: true, regex: localDateTimeRE, groupMap: localDateTimeGroupMap }, + { strict: true, regex: isoDateTimeRE, groupMap: isoDateTimeGroupMap }, + { strict: true, regex: unixDate, groupMap: unixDateGroupMap }, + { strict: true, regex: browserDate, groupMap: browserDateGroupMap }, + { strict: true, regex: utcDate, groupMap: utcDateGroupMap }, + { strict: false, regex: dateVar2, groupMap: dateVar2GroupMap }, + { strict: true, regex: dateVar1, groupMap: dateVar1GroupMap }, + { strict: true, regex: dateVar3, groupMap: dateVar3GroupMap }, + { strict: false, regex: dateVarYearOnly, groupMap: dateVarYearOnlyGroupMap }, + { strict: false, regex: dateVar4, groupMap: dateVar4GroupMap } +] + +function extractDateProps(match: string[], map: GroupMap): DateSpec { + function fixHour(hr: string, amPm?: string) { + if (isNaN(Number(hr))) { + return NaN + } + let newHr = Number(hr) + if (amPm != null && (0 < newHr && newHr <= 12)) { + newHr = newHr % 12 + if (amPm && amPm.toLowerCase() === 'pm') { + newHr += 12 + } + } + return newHr + } + + function fixMonth(m: string) { + if (!isNaN(Number(m))) { + return Number(m) + } + const lcMonth = m.toLowerCase() + const monthIx = monthsArray.findIndex(function (monthName) { return monthName === lcMonth }) + return (monthIx % 12) + 1 + } + + function fixYear(y: string) { + if (y.length === 2) { + const yNumber = Number(y) + if (yNumber < 49) { + return 2000 + yNumber + } else { + return 1900 + yNumber + } + } + else { + return y + } + } + + return { + year: Number(fixYear(match[map.year])), + month: fixMonth(match[map.month] || '1'), + day: Number(match[map.day] || '1'), + hour: fixHour(match[map.hour] || '0', match[map.ampm]), + min: Number(match[map.min] || '0'), + sec: Number(match[map.sec] || '0'), + subsec: Number(match[map.subsec] || '0'), + } +} + +function isValidDateSpec(dateSpec: DateSpec) { + const isValid = + !isNaN(dateSpec.year) && + (!isNaN(dateSpec.month) && (1 <= dateSpec.month && dateSpec.month <= 12)) && + (!isNaN(dateSpec.day) && (1 <= dateSpec.day && dateSpec.day <= 31)) && + (!isNaN(dateSpec.hour) && (0 <= dateSpec.hour && dateSpec.hour <= 23)) && + (!isNaN(dateSpec.min) && (0 <= dateSpec.min && dateSpec.min <= 59)) && + (!isNaN(dateSpec.sec) && (0 <= dateSpec.sec && dateSpec.sec <= 59)) && + !isNaN(dateSpec.subsec) + if (isValid) { return dateSpec } +} + + +export function parseDate(iValue: any, iLoose?: boolean) { + if (iValue == null) { + return null + } + if (iValue instanceof Date) { + return iValue + } + iValue = String(iValue) + let match + let dateSpec + let groupMap: GroupMap | null = null + let date + const spec = formatSpecs.some(function (_spec) { + let m + let parsed = false + if (_spec.strict || iLoose) { + m = iValue.match(_spec.regex) + if (m) { + match = m + groupMap = _spec.groupMap + parsed = true + } + } + return parsed + }) + + if (spec && match && groupMap) { + dateSpec = isValidDateSpec(extractDateProps(match, groupMap)) + if (dateSpec) { + date = new Date(dateSpec.year, (-1 + dateSpec.month), dateSpec.day, + dateSpec.hour, dateSpec.min, dateSpec.sec, dateSpec.subsec) + if (date) date.valueOf = function () { return Date.prototype.valueOf.apply(this) / 1000 } + return date + } + } + return null +} + +/** + * Returns true if the specified value is a string that can be converted to a + * valid date. + * If iLoose is true, applies a looser definition of date. For example, a four + * digit number is interpreted as a year. + */ +export function isDateString(iValue: any, iLoose?: boolean) { + return (typeof iValue === 'string') && !!formatSpecs.find(function (spec) { + if (!(spec.strict || iLoose)) { + return false + } + return spec.regex.test(iValue) + }) +} + diff --git a/v3/src/utilities/date-utils.ts b/v3/src/utilities/date-utils.ts index 42d9752330..887ec826b2 100644 --- a/v3/src/utilities/date-utils.ts +++ b/v3/src/utilities/date-utils.ts @@ -1,27 +1,36 @@ -/* eslint-disable max-len */ +import { isNumeric } from "./data-utils" +import { isDateString } from "./date-parser" import { goodTickValue } from "./math-utils" +import { getDefaultLanguage } from "./translation/translate" + +export enum EDateTimeLevel { + eSecond = 0, + eMinute = 1, + eHour = 2, + eDay = 3, + eMonth = 4, + eYear = 5 +} -/** - @namespace Date value utility functions - */ - // DateTime levels -export const EDateTimeLevel = { - eSecond: 0, - eMinute: 1, - eHour: 2, - eDay: 3, - eMonth: 4, - eYear: 5 - } +export enum DatePrecision { + None = '', + Millisecond = 'millisecond', + Second = 'second', + Minute = 'minute', + Hour = 'hour', + Day = 'day', + Month = 'month', + Year = 'year' +} - export const secondsConverter = { -kSecond: 1000, -kMinute: 1000 * 60, -kHour: ((1000) * 60) * 60, -kDay: (((1000) * 60) * 60) * 24, -kMonth: ((((1000) * 60) * 60) * 24) * 30, -kYear: ((((1000) * 60) * 60) * 24) * 365 - } +export const secondsConverter = { + kSecond: 1000, + kMinute: 1000 * 60, + kHour: ((1000) * 60) * 60, + kDay: (((1000) * 60) * 60) * 24, + kMonth: ((((1000) * 60) * 60) * 24) * 30, + kYear: ((((1000) * 60) * 60) * 24) * 365 +} /** * 1. Compute the outermost date-time level that changes from the @@ -34,10 +43,11 @@ kYear: ((((1000) * 60) * 60) * 24) * 365 * @param iMaxDate { Number } milliseconds * @return {{outerLevel: EDateTimeLevel, innerLevel: EDateTimeLevel, increment: {Number}}} */ -export const determineLevels = (iMinDate: number, iMaxDate: number) => { +export function determineLevels(iMinDate: number, iMaxDate: number) { const tDateDiff = iMaxDate - iMinDate - let tIncrement = 1, // Will only be something else if inner level is year - tOuterLevel, tInnerLevel + let tIncrement = 1 // Will only be something else if inner level is year + let tOuterLevel + let tInnerLevel if (tDateDiff < 3 * secondsConverter.kMinute) { tOuterLevel = EDateTimeLevel.eDay @@ -66,32 +76,73 @@ export const determineLevels = (iMinDate: number, iMaxDate: number) => { } } -/* -DateUtilities.mapLevelToPrecision = function( iLevel) { - var tPrecision = Attribute.DATE_PRECISION_NONE +export function mapLevelToPrecision(iLevel: EDateTimeLevel) { + let tPrecision = DatePrecision.None switch (iLevel) { - case this.EDateTimeLevel.eSecond: - tPrecision = Attribute.DATE_PRECISION_SECOND + case EDateTimeLevel.eSecond: + tPrecision = DatePrecision.Second break - case this.EDateTimeLevel.eMinute: - tPrecision = Attribute.DATE_PRECISION_MINUTE + case EDateTimeLevel.eMinute: + tPrecision = DatePrecision.Minute break - case this.EDateTimeLevel.eHour: - tPrecision = Attribute.DATE_PRECISION_HOUR + case EDateTimeLevel.eHour: + tPrecision = DatePrecision.Hour break - case this.EDateTimeLevel.eDay: - tPrecision = Attribute.DATE_PRECISION_DAY + case EDateTimeLevel.eDay: + tPrecision = DatePrecision.Day break - case this.EDateTimeLevel.eMonth: - tPrecision = Attribute.DATE_PRECISION_MONTH + case EDateTimeLevel.eMonth: + tPrecision = DatePrecision.Month break - case this.EDateTimeLevel.eYear: - tPrecision = Attribute.DATE_PRECISION_YEAR + case EDateTimeLevel.eYear: + tPrecision = DatePrecision.Year break } return tPrecision } -*/ + +/** + Returns true if the specified value is a DG date object. + */ +export function isDate(iValue: any): iValue is Date { + return iValue instanceof Date +} + +/** + * Default formatting for Date objects. + * @param date {Date | number | string } + * @param precision {number} + * @return {string} + */ +export function formatDate(x: Date | number | string, precision: DatePrecision) { + const formatPrecisions: Record = { + [DatePrecision.None]: null, + [DatePrecision.Year]: { year: 'numeric' }, + [DatePrecision.Month]: { year: 'numeric', month: 'numeric' }, + [DatePrecision.Day]: { year: 'numeric', month: 'numeric', day: 'numeric' }, + [DatePrecision.Hour]: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric' }, + [DatePrecision.Minute]: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' }, + [DatePrecision.Second]: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', + second: 'numeric' }, + [DatePrecision.Millisecond]: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', + minute: 'numeric', second: 'numeric', fractionalSecondDigits: 3 } + } + + const precisionFormat = formatPrecisions[precision] || formatPrecisions.minute + + if (!(x && (isDate(x) || isDateString(x) || isNumeric(x)))) { + return null + } + + if (isNumeric(x)) { + x = new Date(x * 1000) + } else if (isDateString(x)) { + x = new Date(x) + } + + const locale = getDefaultLanguage() + return new Intl.DateTimeFormat(locale, precisionFormat).format(x as Date) +} /** Returns true if the specified value should be treated as epoch @@ -99,11 +150,9 @@ DateUtilities.mapLevelToPrecision = function( iLevel) { false if the value should be treated as a year. date(2000) should be treated as a year, but date(12345) should not. */ -/* -DateUtilities.defaultToEpochSecs = function(iValue) { + export function defaultToEpochSecs(iValue: number) { return Math.abs(iValue) >= 5000 } -*/ /** Returns a DG date object constructed from its arguments. @@ -145,59 +194,6 @@ DateUtilities.createDate = function(/!* iArgs *!/) { createDate = DateUtilities.createDate */ -// _isDateRegex = null - -/** - * Returns true if the specified value is a string that can be converted to a - * valid date. - * If iLoose is true, applies a looser definition of date. For example, a four - * digit number is interpreted as a year. - */ -/* -DateUtilities.isDateString = function(iValue, iLoose) { - return DateUtilities.dateParser.isDateString(iValue, iLoose) -} -isDateString = DateUtilities.isDateString -*/ - -/** - * Default formatting for Date objects. - * @param date {Date | number } - * @param precision {number} - * @param useShortFormat {boolean} default is false - * @return {string} - */ - -/* -DateUtilities.formatDate = function(x, precision, useShortFormat) { - var formatPrecisions = { - 'year': {year: 'numeric'}, - 'month': {year: 'numeric', month: 'numeric'}, - 'day': {year: 'numeric', month: 'numeric', day: 'numeric'}, - 'hour': {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric'}, - 'minute': {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric'}, - 'second': {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', - second: 'numeric'}, - 'millisecond': {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', - second: 'numeric', fractionalSecondDigits: 3} - } - - var precisionFormat = formatPrecisions[precision] || formatPrecisions.minute - - if (!(x && (isDate(x) || isDateString(x) || MathUtilities.isNumeric(x)))) return - - if ( MathUtilities.isNumeric(x)) { - x = new Date(x*1000) - } else if (isDateString(x)) { - x = new Date(x) - } - - var locale = get('currentLanguage') - return new Intl.DateTimeFormat(locale, precisionFormat).format(x) -} -formatDate = DateUtilities.formatDate -*/ - /** Default formatting for Date objects. Uses toLocaleDateString() for default date formatting. @@ -230,277 +226,3 @@ DateUtilities.monthName = function(x) { } monthName = DateUtilities.monthName */ - -/** - * parseDate - Parses dates in a uniform manner across browser - * - * Has two modes: strict (default) and loose - * - * In strict mode recognizes year, month or year, month, day iso calendar date - * or date/time strings (not just year) and local dates and date/time strings. - * In loose mode recognizes all iso calendar dates and iso date-time strings and - * a variety of date and date/time formats. - * - * Recognized in strict mode or loose mode, en/US locale: - * * 2019-05 - * * 2019-05-25 - * * 2019-05-25 10:04Z - * * 2019-05-25T10:04Z - * * 2019-05-25T10:04:03+01:00 - * * 2019-05-25T10:04:03.123+01:00 - * * 5/25/2019 - * * 5/25/2019 10:04 - * * 5/25/2019 10:04am - * * 5/25/2019 10:04:32.123 AM - * - * Recognized in loose mode - * * 2019 - * * 2019.05.25 - * * 25 Oct 2019 - * * Oct 25, 2019 - * * Oct. 25, 2019 - * * October 25, 2019 - * Not recognized as dates - * * relative dates (e.g. today, tomorrow, next week) - * * anniversary dates (e.g. June 5) - * * out of range dates (e.g. June 32, 2019) - */ -/* -DateUtilities.dateParser = (function () { - var timePart = '(\\d\\d?)(?::(\\d\\d?)(?::(\\d\\d)(?:\\.(\\d+))?)?)?' - var monthsFull = [ - 'Formula.DateLongMonthJanuary', - 'Formula.DateLongMonthFebruary', - 'Formula.DateLongMonthMarch', - 'Formula.DateLongMonthApril', - 'Formula.DateLongMonthMay', - 'Formula.DateLongMonthJune', - 'Formula.DateLongMonthJuly', - 'Formula.DateLongMonthAugust', - 'Formula.DateLongMonthSeptember', - 'Formula.DateLongMonthOctober', - 'Formula.DateLongMonthNovember', - 'Formula.DateLongMonthDecember' - ].map(function (m) {return m.loc().toLowerCase() }) - - var monthsAbbr = [ - 'Formula.DateShortMonthJanuary', - 'Formula.DateShortMonthFebruary', - 'Formula.DateShortMonthMarch', - 'Formula.DateShortMonthApril', - 'Formula.DateShortMonthMay', - 'Formula.DateShortMonthJune', - 'Formula.DateShortMonthJuly', - 'Formula.DateShortMonthAugust', - 'Formula.DateShortMonthSeptember', - 'Formula.DateShortMonthOctober', - 'Formula.DateShortMonthNovember', - 'Formula.DateShortMonthDecember' - ].map(function(m) {return m.loc().toLowerCase()}) - - var daysOfWeek = [ - "Formula.DateLongDaySunday", - "Formula.DateLongDayMonday", - "Formula.DateLongDayTuesday", - "Formula.DateLongDayWednesday", - "Formula.DateLongDayThursday", - "Formula.DateLongDayFriday", - "Formula.DateLongDaySaturday", - ].map( function (dow) {return dow.loc().toLowerCase()}) - - var daysOfWeekAbbr = [ - "Formula.DateShortDaySunday", - "Formula.DateShortDayMonday", - "Formula.DateShortDayTuesday", - "Formula.DateShortDayWednesday", - "Formula.DateShortDayThursday", - "Formula.DateShortDayFriday", - "Formula.DateShortDaySaturday", - ].map( function (dow) {return dow.loc().toLowerCase()}) - - var monthsProperAbbrRE = monthsAbbr.map(function (str) {return str + '\\.'}) - var monthsProperAbbr = monthsAbbr.map(function (str) {return str + '.'}) - // var ordinals='0th,1st,2nd,3rd,4th,5th,6th,7th,8th,9th' - var monthsArray = monthsAbbr.concat(monthsProperAbbr, monthsFull) - var monthsArrayRE = monthsAbbr.concat(monthsProperAbbrRE, monthsFull) - var daysOfWeekArray = daysOfWeek.concat(daysOfWeekAbbr) - - // yyyy-MM-dd hh:mm:ss.SSSZ - var isoDateTimeRE = - // eslint-disable-next-line max-len - /^(\d{4})-([01]\d)(?:-([0-3]\d)(?:[T ]([0-2]\d)(?::([0-5]\d)(?::([0-5]\d)(?:[.,](\d+))?)?)?(Z|(?:[+-]\d\d:?\d\d?)| ?[a-zA-Z]{1,4}T)?)?)?$/ - var isoDateTimeGroupMap = {year:1, month:2, day:3, hour:4, min:5, sec: 6, subsec: 7, timezone: 8} - - // MM/dd/yyyy hh:mm:ss.SSS PM - // eslint-disable-next-line max-len - var localDateTimeRE = /^([01]?\d)\/([0-3]?\d)\/(\d{4}|\d{2})(?:,? (\d\d?)(?::(\d\d?)(?::(\d\d)(?:\.(\d+))?)?)?(?: ?(am|pm|AM|PM))?)?$/ - var localDateTimeGroupMap = {year:3, month:1, day:2, hour:4, min:5, sec: 6, subsec: 7, ampm: 8, timezone: 9} - - // dd MMM, yyyy or MMM, yyyy - // eslint-disable-next-line max-len - var dateVar1 = new RegExp('^(\\d\\d?) (' + monthsArrayRE.join('|') + '),? (\\d{4})(?: ' + timePart + '(?: (am|pm))?)?$', 'i') - var dateVar1GroupMap = {year:3, month:2, day:1, hour:4, min:5, sec: 6, subsec: 7, ampm: 8} - - // yyyy-mm-dd, yyyy.mm.dd, yyyy/mm/dd - // Require all three parts - var dateVar2 = new RegExp('^(\\d{4})[./-](\\d\\d?)[./-](\\d\\d?)(?: ' + timePart + '(?: (am|pm|AM|PM))?)?$') - var dateVar2GroupMap = {year:1, month:2, day:3, hour:4, min:5, sec: 6, subsec: 7, ampm: 8} - - // MMM dd, yyyy or MMM yyyy - // eslint-disable-next-line max-len - var dateVar3 = new RegExp('^(?:(?:' + daysOfWeekArray.join('|') + '),? )?(' + monthsArrayRE.join('|') + ')(?: (\\d\\d?),)? (\\d{4})(?: ' + timePart + '(?: (am|pm))?)?$', 'i') - var dateVar3GroupMap = {year:3, month:1, day:2, hour:4, min:5, sec: 6, subsec: 7, ampm: 8} - - // 'hh:mm:ss AM/PM on dd/MM/yyyy' - var dateVar4 = /(\d\d?):(\d\d)(?::(\d\d))? (AM|PM) on (\d\d?)\/(\d\d?)\/(\d{4})/ - var dateVar4GroupMap = {year:5, month: 6, day: 7, hour: 1, min: 2, sec: 3, ampm: 4} - - // unix dates: Tue Jul 9 18:16:04 PDT 2019 - // eslint-disable-next-line max-len - var unixDate = new RegExp('^(?:(?:' + daysOfWeekAbbr.join('|') + ') )?(' + monthsAbbr.join('|') + ') ([ \\d]\\d) ([ \\d]\\d):(\\d\\d):(\\d\\d) ([A-Z]{3}) (\\d{4})$', 'i') - var unixDateGroupMap = {year: 7, month: 1, day: 2, hour: 3, min: 4, sec:5, timezone: 6} - - // new Date().toString(), most browsers - // eslint-disable-next-line max-len - var browserDate = new RegExp('^(?:' + daysOfWeekAbbr.join('|') + ') (' + monthsAbbr.join('|') + ') (\\d\\d?),? (\\d{4})(?: ' + timePart + ' (GMT(?:[+-]\\d{4})?(?: \\([\\w ]+\\))?))', 'i') - var browserDateGroupMap = {year:3, month:1, day:2, hour:4, min:5, sec: 6, subsec: 7, timezone: 8} - - // eslint-disable-next-line max-len - var utcDate = new RegExp('^(?:' + daysOfWeekAbbr.join('|') + '),? (\\d\\d?) (' + monthsAbbr.join('|') + ') (\\d{4}) ' + timePart + ' GMT$', 'i') - var utcDateGroupMap = {year:3, month:2, day:1, hour:4, min:5, sec: 6, subsec: 7, timezone: 8} - - // yyyy - var dateVarYearOnly = /^\d{4}$/ - var dateVarYearOnlyGroupMap = {year:0} - - - -// MMMM dd, yyyy hh:mm:ss.SSS PM - - var formatSpecs = [ - { strict: true, regex: localDateTimeRE, groupMap: localDateTimeGroupMap }, - { strict: true, regex: isoDateTimeRE, groupMap: isoDateTimeGroupMap }, - { strict: true, regex: unixDate, groupMap: unixDateGroupMap }, - { strict: true, regex: browserDate, groupMap: browserDateGroupMap}, - { strict: true, regex: utcDate, groupMap: utcDateGroupMap}, - { strict: false, regex: dateVar2, groupMap: dateVar2GroupMap }, - { strict: true, regex: dateVar1, groupMap: dateVar1GroupMap }, - { strict: true, regex: dateVar3, groupMap: dateVar3GroupMap }, - { strict: false, regex: dateVarYearOnly, groupMap: dateVarYearOnlyGroupMap }, - { strict: false, regex: dateVar4, groupMap: dateVar4GroupMap } - ] - - function extractDateProps(match, map) { - function fixHour(hr, amPm) { - if (isNaN(hr)) { - return null - } - var newHr = Number(hr) - if (amPm != null && (0 Date: Tue, 16 Jul 2024 20:51:05 +0200 Subject: [PATCH 02/10] fix: update Cypress case table date formatting test [PT-187965959] --- v3/cypress/e2e/attribute-types.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/v3/cypress/e2e/attribute-types.spec.ts b/v3/cypress/e2e/attribute-types.spec.ts index 19bb2ee7de..cf54dc5562 100644 --- a/v3/cypress/e2e/attribute-types.spec.ts +++ b/v3/cypress/e2e/attribute-types.spec.ts @@ -18,7 +18,15 @@ context("attribute types", () => { table.getCell("3", "2").should("contain", "48") }) it("verify date", () => { - table.getCell("4", "2").should("contain", "8/7/2017 12:01 PM") + table.getCell("4", "2").should("contain", "8/7/2017, 12:01 PM") + table.getCell("4", "3").should("contain", "6/15/1966, 12:00 AM") + table.getCell("4", "4").should("contain", "12/7/1787, 12:00 AM") + table.getCell("4", "5").should("contain", "1/2/2003, 12:00 AM") + table.getCell("4", "6").should("contain", "12/3/1818, 12:00 AM") + table.getCell("4", "7").should("contain", "6/1/1992, 12:00 AM") + table.getCell("4", "8").should("contain", "11/2/1989, 12:00 AM") + table.getCell("4", "9").should("contain", "11/16/2007, 12:00 AM") + table.getCell("4", "10").should("contain", "11/30/2000, 12:00 AM") }) it.skip("verify boolean", () => { table.getCell("5", "2").should("contain", "false") From 0bf1699fd437dbedc601503640896c606a7745a4 Mon Sep 17 00:00:00 2001 From: Piotr Janik Date: Wed, 17 Jul 2024 15:14:13 +0200 Subject: [PATCH 03/10] Update v3/src/utilities/date-parser.test.ts Co-authored-by: Kirk Swenson --- v3/src/utilities/date-parser.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/v3/src/utilities/date-parser.test.ts b/v3/src/utilities/date-parser.test.ts index 116cefeba2..0c2446d452 100644 --- a/v3/src/utilities/date-parser.test.ts +++ b/v3/src/utilities/date-parser.test.ts @@ -26,7 +26,8 @@ test('year.month.day dates', () => { expect(parseDate('2016.6.6', true)?.toISOString()).toBe(new Date(2016, 5, 6).toISOString()) }) test('unix dates', () => { - expect(parseDate('Thu Jul 11 09:12:47 PDT 2019', true)?.toISOString()).toBe(new Date(2019, 6, 11, 9, 12, 47).toISOString()) + expect(parseDate('Thu Jul 11 09:12:47 PDT 2019', true)?.toISOString()) + .toBe(new Date(2019, 6, 11, 9, 12, 47).toISOString()) }) test('UTC dates', () => { expect(parseDate('Thu, 11 Jul 2019 16:17:01 GMT', true)?.toISOString()).toBe(new Date(2019, 6, 11, 16, 17, 1).toISOString()) From 5ed0a196763ac8c242ff1ea86504924fc6d8f4ee Mon Sep 17 00:00:00 2001 From: pjanik Date: Wed, 17 Jul 2024 15:26:01 +0200 Subject: [PATCH 04/10] chore: address PR feedback, fix whitespace in date-parser tests [PT-187965959] --- v3/src/utilities/date-parser.test.ts | 45 ++++++++++++++++------------ 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/v3/src/utilities/date-parser.test.ts b/v3/src/utilities/date-parser.test.ts index 0c2446d452..dc39edb7e3 100644 --- a/v3/src/utilities/date-parser.test.ts +++ b/v3/src/utilities/date-parser.test.ts @@ -10,32 +10,34 @@ describe('Date Parser tests - V2 compatibility', () => { expect(parseDate('11/09/16', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString()) expect(parseDate('04/1/28', true)?.toISOString()).toBe(new Date(2028, 3, 1).toISOString()) }) -test('ISO dates', () => { + test('ISO dates', () => { expect(parseDate('2016-01', true)?.toISOString()).toBe(new Date(2016, 0, 1).toISOString()) expect(parseDate('2016-02-02', true)?.toISOString()).toBe(new Date(2016, 1, 2).toISOString()) }) -test('day, month name, year dates', () => { + test('day, month name, year dates', () => { expect(parseDate('03 Mar 2016', true)?.toISOString()).toBe(new Date(2016, 2, 3).toISOString()) }) -test('traditional US dates', () => { + test('traditional US dates', () => { expect(parseDate('April 4, 2016', true)?.toISOString()).toBe(new Date(2016, 3, 4).toISOString()) expect(parseDate('Apr 5, 2016', true)?.toISOString()).toBe(new Date(2016, 3, 5).toISOString()) expect(parseDate('Monday, May 5, 2016', true)?.toISOString()).toBe(new Date(2016, 4, 5).toISOString()) }) -test('year.month.day dates', () => { + test('year.month.day dates', () => { expect(parseDate('2016.6.6', true)?.toISOString()).toBe(new Date(2016, 5, 6).toISOString()) }) -test('unix dates', () => { - expect(parseDate('Thu Jul 11 09:12:47 PDT 2019', true)?.toISOString()) - .toBe(new Date(2019, 6, 11, 9, 12, 47).toISOString()) + test('unix dates', () => { + expect(parseDate('Thu Jul 11 09:12:47 PDT 2019', true)?.toISOString()) + .toBe(new Date(2019, 6, 11, 9, 12, 47).toISOString()) }) -test('UTC dates', () => { - expect(parseDate('Thu, 11 Jul 2019 16:17:01 GMT', true)?.toISOString()).toBe(new Date(2019, 6, 11, 16, 17, 1).toISOString()) + test('UTC dates', () => { + expect(parseDate('Thu, 11 Jul 2019 16:17:01 GMT', true)?.toISOString()) + .toBe(new Date(2019, 6, 11, 16, 17, 1).toISOString()) }) -test('ISO Date/time', () => { - expect(parseDate('2019-07-11T16:20:38.575Z', true)?.toISOString()).toBe(new Date(2019, 6, 11, 16, 20, 38, 575).toISOString()) + test('ISO Date/time', () => { + expect(parseDate('2019-07-11T16:20:38.575Z', true)?.toISOString()) + .toBe(new Date(2019, 6, 11, 16, 20, 38, 575).toISOString()) }) -test('dates with times', () => { + test('dates with times', () => { expect(parseDate('11/9/2016 7:18', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString()) expect(parseDate('11/9/2016 7:18am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString()) expect(parseDate('11/9/2016 7:18 am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString()) @@ -43,13 +45,18 @@ test('dates with times', () => { expect(parseDate('11/9/2016 7:18:02', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2).toISOString()) expect(parseDate('11/9/2016 7:18:02 AM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2).toISOString()) expect(parseDate('11/9/2016 7:18:02PM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 19, 18, 2).toISOString()) - expect(parseDate('11/9/2016 7:18:02.123', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2, 123).toISOString()) - expect(parseDate('11/9/2016 7:18:02.234 pm', true)?.toISOString()).toBe(new Date(2016, 10, 9, 19, 18, 2, 234).toISOString()) - expect(parseDate('11/9/2016 7:18:02.234am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2, 234).toISOString()) - expect(parseDate('11/9/2016 17:18:02', true)?.toISOString()).toBe(new Date(2016, 10, 9, 17, 18, 2).toISOString()) - expect(parseDate('11/9/2016 17:18:02.123', true)?.toISOString()).toBe(new Date(2016, 10, 9, 17, 18, 2, 123).toISOString()) + expect(parseDate('11/9/2016 7:18:02.123', true)?.toISOString()) + .toBe(new Date(2016, 10, 9, 7, 18, 2, 123).toISOString()) + expect(parseDate('11/9/2016 7:18:02.234 pm', true)?.toISOString()) + .toBe(new Date(2016, 10, 9, 19, 18, 2, 234).toISOString()) + expect(parseDate('11/9/2016 7:18:02.234am', true)?.toISOString()) + .toBe(new Date(2016, 10, 9, 7, 18, 2, 234).toISOString()) + expect(parseDate('11/9/2016 17:18:02', true)?.toISOString()) + .toBe(new Date(2016, 10, 9, 17, 18, 2).toISOString()) + expect(parseDate('11/9/2016 17:18:02.123', true)?.toISOString()) + .toBe(new Date(2016, 10, 9, 17, 18, 2, 123).toISOString()) }) -test('ISO 8601', () => { + test('ISO 8601', () => { expect(isDateString('2016-11-10')).toBe(true) expect(isDateString('2016-11-09T07:18:02')).toBe(true) expect(isDateString('2016-11-09T07:18:02-07:00')).toBe(true) @@ -62,7 +69,7 @@ test('ISO 8601', () => { expect(isDateString('September 1, 2016')).toBe(true) expect(isDateString('2016-11-10T21:27:42.12Z')).toBe(true) }) -test('invalid strings', () => { + test('invalid strings', () => { expect(isDateString('')).toBe(false) expect(isDateString('a')).toBe(false) expect(isDateString('123')).toBe(false) From 3bee51bcc6208734bae6bfcb620e7c082485c633 Mon Sep 17 00:00:00 2001 From: pjanik Date: Wed, 17 Jul 2024 15:27:26 +0200 Subject: [PATCH 05/10] chore: address PR feedback, isNumeric -> isFiniteNumber [PT-187965959] --- v3/src/utilities/data-utils.ts | 4 ---- v3/src/utilities/date-utils.ts | 9 ++++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/v3/src/utilities/data-utils.ts b/v3/src/utilities/data-utils.ts index d597782dd4..292832ba88 100644 --- a/v3/src/utilities/data-utils.ts +++ b/v3/src/utilities/data-utils.ts @@ -42,7 +42,3 @@ export const numericSortComparator = function ({a, b, order}: ICompareProps): nu if (aIsNaN) return order === "asc" ? -1 : 1 return order === "asc" ? a - b : b - a } - -export const isNumeric = function (value: any): value is number { - return value != null && value !== "" && !isNaN(value) -} diff --git a/v3/src/utilities/date-utils.ts b/v3/src/utilities/date-utils.ts index 887ec826b2..39196e47ae 100644 --- a/v3/src/utilities/date-utils.ts +++ b/v3/src/utilities/date-utils.ts @@ -1,6 +1,5 @@ -import { isNumeric } from "./data-utils" import { isDateString } from "./date-parser" -import { goodTickValue } from "./math-utils" +import { goodTickValue, isFiniteNumber } from "./math-utils" import { getDefaultLanguage } from "./translation/translate" export enum EDateTimeLevel { @@ -130,11 +129,11 @@ export function formatDate(x: Date | number | string, precision: DatePrecision) const precisionFormat = formatPrecisions[precision] || formatPrecisions.minute - if (!(x && (isDate(x) || isDateString(x) || isNumeric(x)))) { + if (!(x && (isDate(x) || isDateString(x) || isFiniteNumber(x)))) { return null } - if (isNumeric(x)) { + if (isFiniteNumber(x)) { x = new Date(x * 1000) } else if (isDateString(x)) { x = new Date(x) @@ -201,7 +200,7 @@ createDate = DateUtilities.createDate */ /* DateUtilities.monthName = function(x) { - if (!(x && (isDate(x) || isDateString(x) || MathUtilities.isNumeric(x)))) return "" + if (!(x && (isDate(x) || isDateString(x) || MathUtilities.isFiniteNumber(x)))) return "" var date if (isDate(x)) date = x From 21da893a6103b0231c8c140674cb8e99dcf74597 Mon Sep 17 00:00:00 2001 From: Piotr Janik Date: Wed, 17 Jul 2024 15:29:36 +0200 Subject: [PATCH 06/10] Update v3/src/components/case-table/use-columns.tsx Co-authored-by: Kirk Swenson --- v3/src/components/case-table/use-columns.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v3/src/components/case-table/use-columns.tsx b/v3/src/components/case-table/use-columns.tsx index a82fa63380..8ccfcf43c9 100644 --- a/v3/src/components/case-table/use-columns.tsx +++ b/v3/src/components/case-table/use-columns.tsx @@ -64,7 +64,7 @@ export function renderValue(str = "", num = NaN, attr?: IAttribute, key?: number const formattedDate = formatDate(date, DatePrecision.None) return { value: str, - content: {formattedDate || str} + content: {formattedDate || `"${str}"`} } } else { // If the date is not valid, wrap it in quotes (CODAP V2 behavior). From bfcfd8d2cfbeed862feb128848fbd4a2ad7f0e4c Mon Sep 17 00:00:00 2001 From: Piotr Janik Date: Wed, 17 Jul 2024 15:29:51 +0200 Subject: [PATCH 07/10] Update v3/src/utilities/date-parser.ts Co-authored-by: Kirk Swenson --- v3/src/utilities/date-parser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v3/src/utilities/date-parser.ts b/v3/src/utilities/date-parser.ts index 6c110e13d9..9984fc4807 100644 --- a/v3/src/utilities/date-parser.ts +++ b/v3/src/utilities/date-parser.ts @@ -8,7 +8,7 @@ import { t } from "./translation/translate" * In strict mode recognizes year, month or year, month, day iso calendar date * or date/time strings (not just year) and local dates and date/time strings. * In loose mode recognizes all iso calendar dates and iso date-time strings and - * a constiety of date and date/time formats. + * a variety of date and date/time formats. * * Recognized in strict mode or loose mode, en/US locale: * * 2019-05 From fb35f8cf74a248473befa2a71f88483d43cb4496 Mon Sep 17 00:00:00 2001 From: pjanik Date: Wed, 17 Jul 2024 16:10:04 +0200 Subject: [PATCH 08/10] chore: add isValidDateSpec tests [PT-187965959] --- v3/src/utilities/date-parser.test.ts | 108 ++++++++++++++++++++++++++- v3/src/utilities/date-parser.ts | 6 +- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/v3/src/utilities/date-parser.test.ts b/v3/src/utilities/date-parser.test.ts index dc39edb7e3..33d34a6618 100644 --- a/v3/src/utilities/date-parser.test.ts +++ b/v3/src/utilities/date-parser.test.ts @@ -1,4 +1,4 @@ -import { isDateString, parseDate } from './date-parser' +import { isDateString, isValidDateSpec, parseDate } from './date-parser' describe('Date Parser tests - V2 compatibility', () => { // These tests are ported from V2 and should always pass unchanged as long as we want to maintain compatibility. @@ -86,3 +86,109 @@ describe('Date Parser tests - V2 compatibility', () => { expect(isDateString('2016-1-10T1:07:42.123Z')).toBe(false) }) }) + +describe('isValidDateSpec', () => { + test('returns dateSpec when all values are valid', () => { + const validDateSpec = { + year: 2023, + month: 7, + day: 17, + hour: 15, + min: 30, + sec: 45, + subsec: 123 + }; + expect(isValidDateSpec(validDateSpec)).toEqual(validDateSpec); + }); + + test('returns null when year is NaN', () => { + const invalidDateSpec = { + year: NaN, + month: 7, + day: 17, + hour: 15, + min: 30, + sec: 45, + subsec: 123 + }; + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); + }); + + test('returns null when month is out of range', () => { + const invalidDateSpec = { + year: 2023, + month: 13, + day: 17, + hour: 15, + min: 30, + sec: 45, + subsec: 123 + }; + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); + }); + + test('returns null when day is out of range', () => { + const invalidDateSpec = { + year: 2023, + month: 7, + day: 32, + hour: 15, + min: 30, + sec: 45, + subsec: 123 + }; + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); + }); + + test('returns null when hour is out of range', () => { + const invalidDateSpec = { + year: 2023, + month: 7, + day: 17, + hour: 24, + min: 30, + sec: 45, + subsec: 123 + }; + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); + }); + + test('returns null when minute is out of range', () => { + const invalidDateSpec = { + year: 2023, + month: 7, + day: 17, + hour: 15, + min: 60, + sec: 45, + subsec: 123 + }; + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); + }); + + test('returns null when second is out of range', () => { + const invalidDateSpec = { + year: 2023, + month: 7, + day: 17, + hour: 15, + min: 30, + sec: 60, + subsec: 123 + }; + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); + }); + + test('returns null when subsecond is NaN', () => { + const invalidDateSpec = { + year: 2023, + month: 7, + day: 17, + hour: 15, + min: 30, + sec: 45, + subsec: NaN + }; + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); + }); +}); diff --git a/v3/src/utilities/date-parser.ts b/v3/src/utilities/date-parser.ts index 9984fc4807..e87955b0ba 100644 --- a/v3/src/utilities/date-parser.ts +++ b/v3/src/utilities/date-parser.ts @@ -218,7 +218,7 @@ function extractDateProps(match: string[], map: GroupMap): DateSpec { } } -function isValidDateSpec(dateSpec: DateSpec) { +export function isValidDateSpec(dateSpec: DateSpec) { const isValid = !isNaN(dateSpec.year) && (!isNaN(dateSpec.month) && (1 <= dateSpec.month && dateSpec.month <= 12)) && @@ -227,9 +227,9 @@ function isValidDateSpec(dateSpec: DateSpec) { (!isNaN(dateSpec.min) && (0 <= dateSpec.min && dateSpec.min <= 59)) && (!isNaN(dateSpec.sec) && (0 <= dateSpec.sec && dateSpec.sec <= 59)) && !isNaN(dateSpec.subsec) - if (isValid) { return dateSpec } -} + return isValid ? dateSpec : false +} export function parseDate(iValue: any, iLoose?: boolean) { if (iValue == null) { From 0ab4e004eef52160d28e5b22d5116290c7eaa120 Mon Sep 17 00:00:00 2001 From: pjanik Date: Wed, 17 Jul 2024 20:08:45 +0200 Subject: [PATCH 09/10] chore: update isFiniteNumber implementation, specs and bug in date-utils [PT-187965959] --- v3/src/utilities/date-parser.ts | 2 +- v3/src/utilities/date-utils.ts | 8 ++++++-- v3/src/utilities/math-utils.test.ts | 12 ++++++++++++ v3/src/utilities/math-utils.ts | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/v3/src/utilities/date-parser.ts b/v3/src/utilities/date-parser.ts index e87955b0ba..c381caa92c 100644 --- a/v3/src/utilities/date-parser.ts +++ b/v3/src/utilities/date-parser.ts @@ -240,7 +240,7 @@ export function parseDate(iValue: any, iLoose?: boolean) { } iValue = String(iValue) let match - let dateSpec + let dateSpec: DateSpec | false let groupMap: GroupMap | null = null let date const spec = formatSpecs.some(function (_spec) { diff --git a/v3/src/utilities/date-utils.ts b/v3/src/utilities/date-utils.ts index 39196e47ae..ef7cb5d8a6 100644 --- a/v3/src/utilities/date-utils.ts +++ b/v3/src/utilities/date-utils.ts @@ -133,8 +133,12 @@ export function formatDate(x: Date | number | string, precision: DatePrecision) return null } - if (isFiniteNumber(x)) { - x = new Date(x * 1000) + if (isFiniteNumber(x) || isDate(x)) { + // Note that this differs from the original implementation in V2 date_utilities.js because isFiniteNumber behaves + // differently from DG.MathUtilities.isNumeric in V2. The original isNumeric function in V2 returns true for + // Date objects, which was probably not planned (as `isNaN(new Date())` actually returns `false`), but is necessary + // here. Since isFiniteNumber() is more strict, we need an explicit check for Date objects here. + x = new Date(x.valueOf() * 1000) } else if (isDateString(x)) { x = new Date(x) } diff --git a/v3/src/utilities/math-utils.test.ts b/v3/src/utilities/math-utils.test.ts index e99eaaa679..ea51df7370 100644 --- a/v3/src/utilities/math-utils.test.ts +++ b/v3/src/utilities/math-utils.test.ts @@ -49,12 +49,24 @@ describe("math-utils", () => { expect(isFiniteNumber(-Infinity)).toBe(false) expect(isFiniteNumber(NaN)).toBe(false) }) + it("should return true for numbers that are strings", () => { + expect(isFiniteNumber("1")).toBe(true) + expect(isFiniteNumber("1.1")).toBe(true) + expect(isFiniteNumber("0")).toBe(true) + expect(isFiniteNumber("Infinity")).toBe(false) + expect(isFiniteNumber("-Infinity")).toBe(false) + expect(isFiniteNumber("NaN")).toBe(false) + }) it("should return false for non-numbers", () => { + expect(isFiniteNumber("")).toBe(false) expect(isFiniteNumber("foo")).toBe(false) expect(isFiniteNumber({})).toBe(false) expect(isFiniteNumber([])).toBe(false) expect(isFiniteNumber(null)).toBe(false) expect(isFiniteNumber(undefined)).toBe(false) + expect(isFiniteNumber(true)).toBe(false) + expect(isFiniteNumber(false)).toBe(false) + expect(isFiniteNumber(() => {})).toBe(false) }) }) }) diff --git a/v3/src/utilities/math-utils.ts b/v3/src/utilities/math-utils.ts index 6b39af660b..adfe6daf6c 100644 --- a/v3/src/utilities/math-utils.ts +++ b/v3/src/utilities/math-utils.ts @@ -105,7 +105,7 @@ export function neededSigDigitsArrayForQuantiles(quantiles: number[], values: nu } export function isFiniteNumber(x: any): x is number { - return x != null && Number.isFinite(x) + return Number.isFinite(Number.parseFloat(x)) } export function goodTickValue(iMin: number, iMax: number) { From 45afcf51e50779bb39a3ec782d5bf7b7c987e561 Mon Sep 17 00:00:00 2001 From: pjanik Date: Thu, 18 Jul 2024 15:41:29 +0200 Subject: [PATCH 10/10] chore: restore original isFiniteNumber implementation, lint fixes [PT-187965959] --- v3/src/utilities/date-parser.test.ts | 71 +++++++++++++--------------- v3/src/utilities/math-utils.test.ts | 8 ---- v3/src/utilities/math-utils.ts | 2 +- 3 files changed, 33 insertions(+), 48 deletions(-) diff --git a/v3/src/utilities/date-parser.test.ts b/v3/src/utilities/date-parser.test.ts index 33d34a6618..7ed7a32415 100644 --- a/v3/src/utilities/date-parser.test.ts +++ b/v3/src/utilities/date-parser.test.ts @@ -97,11 +97,10 @@ describe('isValidDateSpec', () => { min: 30, sec: 45, subsec: 123 - }; - expect(isValidDateSpec(validDateSpec)).toEqual(validDateSpec); - }); - - test('returns null when year is NaN', () => { + } + expect(isValidDateSpec(validDateSpec)).toEqual(validDateSpec) + }) +test('returns null when year is NaN', () => { const invalidDateSpec = { year: NaN, month: 7, @@ -110,11 +109,10 @@ describe('isValidDateSpec', () => { min: 30, sec: 45, subsec: 123 - }; - expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); - }); - - test('returns null when month is out of range', () => { + } + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy() + }) +test('returns null when month is out of range', () => { const invalidDateSpec = { year: 2023, month: 13, @@ -123,11 +121,10 @@ describe('isValidDateSpec', () => { min: 30, sec: 45, subsec: 123 - }; - expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); - }); - - test('returns null when day is out of range', () => { + } + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy() + }) +test('returns null when day is out of range', () => { const invalidDateSpec = { year: 2023, month: 7, @@ -136,11 +133,10 @@ describe('isValidDateSpec', () => { min: 30, sec: 45, subsec: 123 - }; - expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); - }); - - test('returns null when hour is out of range', () => { + } + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy() + }) +test('returns null when hour is out of range', () => { const invalidDateSpec = { year: 2023, month: 7, @@ -149,11 +145,10 @@ describe('isValidDateSpec', () => { min: 30, sec: 45, subsec: 123 - }; - expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); - }); - - test('returns null when minute is out of range', () => { + } + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy() + }) +test('returns null when minute is out of range', () => { const invalidDateSpec = { year: 2023, month: 7, @@ -162,11 +157,10 @@ describe('isValidDateSpec', () => { min: 60, sec: 45, subsec: 123 - }; - expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); - }); - - test('returns null when second is out of range', () => { + } + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy() + }) +test('returns null when second is out of range', () => { const invalidDateSpec = { year: 2023, month: 7, @@ -175,11 +169,10 @@ describe('isValidDateSpec', () => { min: 30, sec: 60, subsec: 123 - }; - expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); - }); - - test('returns null when subsecond is NaN', () => { + } + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy() + }) +test('returns null when subsecond is NaN', () => { const invalidDateSpec = { year: 2023, month: 7, @@ -188,7 +181,7 @@ describe('isValidDateSpec', () => { min: 30, sec: 45, subsec: NaN - }; - expect(isValidDateSpec(invalidDateSpec)).toBeFalsy(); - }); -}); + } + expect(isValidDateSpec(invalidDateSpec)).toBeFalsy() + }) +}) diff --git a/v3/src/utilities/math-utils.test.ts b/v3/src/utilities/math-utils.test.ts index ea51df7370..ac4038bb8b 100644 --- a/v3/src/utilities/math-utils.test.ts +++ b/v3/src/utilities/math-utils.test.ts @@ -49,14 +49,6 @@ describe("math-utils", () => { expect(isFiniteNumber(-Infinity)).toBe(false) expect(isFiniteNumber(NaN)).toBe(false) }) - it("should return true for numbers that are strings", () => { - expect(isFiniteNumber("1")).toBe(true) - expect(isFiniteNumber("1.1")).toBe(true) - expect(isFiniteNumber("0")).toBe(true) - expect(isFiniteNumber("Infinity")).toBe(false) - expect(isFiniteNumber("-Infinity")).toBe(false) - expect(isFiniteNumber("NaN")).toBe(false) - }) it("should return false for non-numbers", () => { expect(isFiniteNumber("")).toBe(false) expect(isFiniteNumber("foo")).toBe(false) diff --git a/v3/src/utilities/math-utils.ts b/v3/src/utilities/math-utils.ts index adfe6daf6c..6b39af660b 100644 --- a/v3/src/utilities/math-utils.ts +++ b/v3/src/utilities/math-utils.ts @@ -105,7 +105,7 @@ export function neededSigDigitsArrayForQuantiles(quantiles: number[], values: nu } export function isFiniteNumber(x: any): x is number { - return Number.isFinite(Number.parseFloat(x)) + return x != null && Number.isFinite(x) } export function goodTickValue(iMin: number, iMax: number) {