diff --git a/packages/react/package.json b/packages/react/package.json index 55f88b35d..8a9cafe84 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -50,8 +50,6 @@ "@0no-co/graphql.web": "^1.0.4", "@gadgetinc/api-client-core": "^0.15.24", "@hookform/resolvers": "^3.3.1", - "date-fns": "^2.30.0", - "date-fns-tz": "^2.0.0", "filesize": "^10.1.2", "pluralize": "^8.0.0", "react-fast-compare": "^3.2.2", @@ -113,7 +111,9 @@ "setup-polly-jest": "^0.11.0", "storybook": "^8.1.6", "tmp": "^0.2.3", - "wonka": "^6.3.2" + "wonka": "^6.3.2", + "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.0" }, "peerDependencies": { "@mdxeditor/editor": "^3.8.0", diff --git a/packages/react/src/auto/mui/inputs/MUIAutoDateTimePicker.tsx b/packages/react/src/auto/mui/inputs/MUIAutoDateTimePicker.tsx index 5ba4b8e15..0fdf76cd5 100644 --- a/packages/react/src/auto/mui/inputs/MUIAutoDateTimePicker.tsx +++ b/packages/react/src/auto/mui/inputs/MUIAutoDateTimePicker.tsx @@ -1,8 +1,8 @@ import { Box } from "@mui/material"; import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { zonedTimeToUtc } from "date-fns-tz"; import React from "react"; import { useController } from "react-hook-form"; +import { zonedTimeToUtc } from "../../../dateTimeUtils.js"; import type { GadgetDateTimeConfig } from "../../../internal/gql/graphql.js"; import { useFieldMetadata } from "../../hooks/useFieldMetadata.js"; diff --git a/packages/react/src/auto/polaris/inputs/PolarisAutoDateTimePicker.tsx b/packages/react/src/auto/polaris/inputs/PolarisAutoDateTimePicker.tsx index 46f911c00..b726a7d8b 100644 --- a/packages/react/src/auto/polaris/inputs/PolarisAutoDateTimePicker.tsx +++ b/packages/react/src/auto/polaris/inputs/PolarisAutoDateTimePicker.tsx @@ -1,10 +1,9 @@ import type { DatePickerProps, TextFieldProps } from "@shopify/polaris"; import { DatePicker, Icon, InlineStack, Popover, TextField } from "@shopify/polaris"; import { CalendarIcon } from "@shopify/polaris-icons"; -import { format, isValid } from "date-fns"; -import { utcToZonedTime, zonedTimeToUtc } from "date-fns-tz"; import React, { useCallback, useMemo, useState } from "react"; import { useController } from "react-hook-form"; +import { formatShortDateString, isValidDate, utcToZonedTime, zonedTimeToUtc } from "../../../dateTimeUtils.js"; import type { GadgetDateTimeConfig } from "../../../internal/gql/graphql.js"; import { useFieldMetadata } from "../../hooks/useFieldMetadata.js"; import type { DateTimeState } from "./PolarisAutoTimePicker.js"; @@ -60,7 +59,7 @@ export const PolarisAutoDateTimePicker = (props: { const { onChange, value } = props; const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone; const localTime = useMemo(() => { - return value ? value : isValid(new Date(fieldProps.value)) ? new Date(fieldProps.value) : undefined; + return value ? value : isValidDate(new Date(fieldProps.value)) ? new Date(fieldProps.value) : undefined; }, [value, fieldProps.value]); const [datePopoverActive, setDatePopoverActive] = useState(false); @@ -74,7 +73,7 @@ export const PolarisAutoDateTimePicker = (props: { (range) => { (fieldProps || value) && copyTime(range.start, zonedTimeToUtc(range.start, localTz)); const dateOverride = value ?? new Date(fieldProps.value); - if (isValid(dateOverride)) { + if (isValidDate(dateOverride)) { range.start.setHours(dateOverride.getHours()); range.start.setMinutes(dateOverride.getMinutes()); range.start.setSeconds(dateOverride.getSeconds()); @@ -88,8 +87,8 @@ export const PolarisAutoDateTimePicker = (props: { ); const toggleDatePopoverActive = useCallback(() => { - setPopoverMonth(getDateTimeObjectFromDate(isValid(localTime) && localTime ? localTime : new Date()).month); - setPopoverYear(getDateTimeObjectFromDate(isValid(localTime) && localTime ? localTime : new Date()).year); + setPopoverMonth(getDateTimeObjectFromDate(isValidDate(localTime) && localTime ? localTime : new Date()).month); + setPopoverYear(getDateTimeObjectFromDate(isValidDate(localTime) && localTime ? localTime : new Date()).year); setDatePopoverActive((active) => !active); }, [localTime]); const handleMonthChange = useCallback((month: number, year: number) => { @@ -108,7 +107,7 @@ export const PolarisAutoDateTimePicker = (props: { label={metadata.name ?? "Date"} prefix={} autoComplete="off" - value={localTime ? format(localTime, "yyyy-MM-dd") : ""} + value={localTime ? formatShortDateString(localTime) : ""} onFocus={toggleDatePopoverActive} error={props.error} /> diff --git a/packages/react/src/auto/polaris/inputs/PolarisAutoTimePicker.tsx b/packages/react/src/auto/polaris/inputs/PolarisAutoTimePicker.tsx index 70b822add..e01f9915d 100644 --- a/packages/react/src/auto/polaris/inputs/PolarisAutoTimePicker.tsx +++ b/packages/react/src/auto/polaris/inputs/PolarisAutoTimePicker.tsx @@ -1,9 +1,9 @@ import type { TextFieldProps } from "@shopify/polaris"; import { Box, Icon, Listbox, Popover, Scrollable, Text, TextField } from "@shopify/polaris"; import { ClockIcon } from "@shopify/polaris-icons"; -import { zonedTimeToUtc } from "date-fns-tz"; import React, { useEffect, useState } from "react"; import type { ControllerRenderProps, FieldValues } from "react-hook-form"; +import { zonedTimeToUtc } from "../../../dateTimeUtils.js"; import { copyTime, getDateFromDateTimeObject, getDateTimeObjectFromDate } from "./PolarisAutoDateTimePicker.js"; const createMarkup = ( diff --git a/packages/react/src/auto/polaris/tableCells/PolarisAutoTableDateTimeCell.tsx b/packages/react/src/auto/polaris/tableCells/PolarisAutoTableDateTimeCell.tsx index 5f9c5c5f0..c14b59c1e 100644 --- a/packages/react/src/auto/polaris/tableCells/PolarisAutoTableDateTimeCell.tsx +++ b/packages/react/src/auto/polaris/tableCells/PolarisAutoTableDateTimeCell.tsx @@ -1,11 +1,9 @@ -import { format } from "date-fns"; import React from "react"; +import { formatLongDateTimeString } from "../../../dateTimeUtils.js"; import { PolarisAutoTableTextCell } from "./PolarisAutoTableTextCell.js"; export const PolarisAutoTableDateTimeCell = (props: { value: Date; includeTime: boolean }) => { const { value, includeTime } = props; - const timeFormat = includeTime ? "LLL d, y K:mm a" : "LLL d, y"; - - return value instanceof Date ? : null; + return value instanceof Date ? : null; }; diff --git a/packages/react/src/dateTimeUtils.ts b/packages/react/src/dateTimeUtils.ts new file mode 100644 index 000000000..b19d5bbf0 --- /dev/null +++ b/packages/react/src/dateTimeUtils.ts @@ -0,0 +1,375 @@ +/** + * Formats a date object in a "yyyy-MM-dd" format + * Ex: 2000-01-30 + * @param date + * @returns Formatted date string, if date is undefined, formatShortDateString returns an empty string + */ +export const formatShortDateString = (date: Date) => { + if (!date) return ""; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; + +/** + * Formats a date object in a "LLL d, y K:mm a" format + * Ex: Jun 30, 2024 8:00 PM + * @param date + * @returns Formatted date string, if date is undefined, formatShortDateString returns an empty string + */ +export const formatLongDateTimeString = (date: Date, includeTime: boolean) => { + if (!date) return ""; + + const dateString = date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + + if (includeTime) { + const timeString = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + + return `${dateString} ${timeString}`; + } else return dateString; +}; + +/** + * + * @param date + * @returns + */ +export const isValidDate = (date: unknown) => { + return date instanceof Date && !isNaN(date.getTime()); +}; + +/** + * @name zonedTimeToUtc + * (called "toZonedTime" in the original date-fns-tz repo) + * @category Time Zone Helpers + * @summary Get the UTC date/time from a date representing local time in a given time zone + * @author https://github.com/marnusw + * Taken from https://github.com/marnusw/date-fns-tz + * + * @description + * Returns a date instance with the UTC time of the provided date of which the values + * represented the local time in the time zone specified. In other words, if the input + * date represented local time in time zone, the timestamp of the output date will + * give the equivalent UTC of that local time regardless of the current system time zone. + * + * @param date the date with values representing the local time + * @param timeZone the time zone of this local time, can be an offset or IANA time zone + * @param options the object with options. See [Options]{@link https://date-fns.org/docs/Options} + * @param {0|1|2} [options.additionalDigits=2] - passed to `toDate`. See [toDate]{@link https://date-fns.org/docs/toDate} + * @throws {TypeError} 2 arguments required + * @throws {RangeError} `options.additionalDigits` must be 0, 1 or 2 + * + * @example + * // In June 10am in Los Angeles is 5pm UTC + * const result = fromZonedTime(new Date(2014, 5, 25, 10, 0, 0), 'America/Los_Angeles') + * //=> 2014-06-25T17:00:00.000Z + */ +export const zonedTimeToUtc = (date: Date, timeZone: string): Date => { + const utc = newDateUTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds() + ).getTime(); + + const offsetMilliseconds = tzParseTimezone(timeZone, new Date(utc)); + + return new Date(utc + offsetMilliseconds); +}; + +/** + * @name utcToZonedTime + * (called "toZonedTime" in the original date-fns-tz repo) + * @category Time Zone Helpers + * @summary Get a date/time representing local time in a given time zone from the UTC date + * @author https://github.com/marnusw + * Taken from https://github.com/marnusw/date-fns-tz + * + * @description + * Returns a date instance with values representing the local time in the time zone + * specified of the UTC time from the date provided. In other words, when the new date + * is formatted it will show the equivalent hours in the target time zone regardless + * of the current system time zone. + * + * @param date the date with the relevant UTC time + * @param timeZone the time zone to get local time for, can be an offset or IANA time zone + * @param options the object with options. See [Options]{@link https://date-fns.org/docs/Options} + * @param {0|1|2} [options.additionalDigits=2] - passed to `toDate`. See [toDate]{@link https://date-fns.org/docs/toDate} + * + * @throws {TypeError} 2 arguments required + * @throws {RangeError} `options.additionalDigits` must be 0, 1 or 2 + * + * @example + * // In June 10am UTC is 6am in New York (-04:00) + * const result = toZonedTime('2014-06-25T10:00:00.000Z', 'America/New_York') + * //=> Jun 25 2014 06:00:00 + */ +export const utcToZonedTime = (date: Date, timeZone: string): Date => { + const offsetMilliseconds = tzParseTimezone(timeZone, date, true); + + const d = new Date(date.getTime() - offsetMilliseconds); + + const resultDate = new Date(0); + + resultDate.setFullYear(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + + resultDate.setHours(d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()); + + return resultDate; +}; + +// Helper functions for utcToZonedTime +const patterns = { + timezone: /([Z+-].*)$/, + timezoneZ: /^(Z)$/, + timezoneHH: /^([+-]\d{2})$/, + timezoneHHMM: /^([+-])(\d{2}):?(\d{2})$/, +}; + +const validIANATimezoneCache: Record = {}; + +const MILLISECONDS_IN_HOUR = 3600000; +const MILLISECONDS_IN_MINUTE = 60000; + +const isValidTimezoneIANAString = (timeZoneString: string) => { + if (validIANATimezoneCache[timeZoneString]) return true; + try { + new Intl.DateTimeFormat(undefined, { timeZone: timeZoneString }); + validIANATimezoneCache[timeZoneString] = true; + return true; + } catch (error) { + return false; + } +}; + +export const tzParseTimezone = (timezoneString: string | undefined, date: Date | number | undefined, isUtcDate?: boolean): number => { + // Empty string + if (!timezoneString) { + return 0; + } + + // Z + let token = patterns.timezoneZ.exec(timezoneString); + if (token) { + return 0; + } + + let hours: number; + let absoluteOffset: number; + + // ±hh + token = patterns.timezoneHH.exec(timezoneString); + if (token) { + hours = parseInt(token[1], 10); + + if (!validateTimezone(hours)) { + return NaN; + } + + return -(hours * MILLISECONDS_IN_HOUR); + } + + // ±hh:mm or ±hhmm + token = patterns.timezoneHHMM.exec(timezoneString); + if (token) { + hours = parseInt(token[2], 10); + const minutes = parseInt(token[3], 10); + + if (!validateTimezone(hours, minutes)) { + return NaN; + } + + absoluteOffset = Math.abs(hours) * MILLISECONDS_IN_HOUR + minutes * MILLISECONDS_IN_MINUTE; + return token[1] === "+" ? -absoluteOffset : absoluteOffset; + } + + const fixOffset = (date: Date, offset: number, timezoneString: string) => { + const localTS = date.getTime(); + + // Our UTC time is just a guess because our offset is just a guess + let utcGuess = localTS - offset; + + // Test whether the zone matches the offset for this ts + const o2 = calcOffset(new Date(utcGuess), timezoneString); + + // If so, offset didn't change, and we're done + if (offset === o2) { + return offset; + } + + // If not, change the ts by the difference in the offset + utcGuess -= o2 - offset; + + // If that gives us the local time we want, we're done + const o3 = calcOffset(new Date(utcGuess), timezoneString); + if (o2 === o3) { + return o2; + } + + // If it's different, we're in a hole time. The offset has changed, but we don't adjust the time + return Math.max(o2, o3); + }; + + // IANA time zone + if (isValidTimezoneIANAString(timezoneString)) { + date = new Date(date || Date.now()); + const utcDate = isUtcDate ? date : toUtcDate(date); + + const offset = calcOffset(utcDate, timezoneString); + + const fixedOffset = isUtcDate ? offset : fixOffset(date, offset, timezoneString); + + return -fixedOffset; + } + + return NaN; +}; + +const validateTimezone = (hours: number, minutes?: number | null) => { + return -23 <= hours && hours <= 23 && (minutes == null || (0 <= minutes && minutes <= 59)); +}; + +const calcOffset = (date: Date, timezoneString: string) => { + const tokens = tzTokenizeDate(date, timezoneString); + + // ms dropped because it's not provided by tzTokenizeDate + const asUTC = newDateUTC(tokens[0], tokens[1] - 1, tokens[2], tokens[3] % 24, tokens[4], tokens[5], 0).getTime(); + + let asTS = date.getTime(); + const over = asTS % 1000; + asTS -= over >= 0 ? over : 1000 + over; + return asUTC - asTS; +}; + +const toUtcDate = (date: Date) => { + return newDateUTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds() + ); +}; + +const newDateUTC = ( + fullYear: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + millisecond: number +): Date => { + const utcDate = new Date(0); + utcDate.setUTCFullYear(fullYear, month, day); + utcDate.setUTCHours(hour, minute, second, millisecond); + return utcDate; +}; + +const tzTokenizeDate = (date: Date, timeZone: string): number[] => { + const dtf = getDateTimeFormat(timeZone); + return "formatToParts" in dtf ? partsOffset(dtf, date) : hackyOffset(dtf, date); +}; + +const dtfCache: Record = {}; + +const getDateTimeFormat = (timeZone: string) => { + if (!dtfCache[timeZone]) { + // New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` + const testDateFormatted = new Intl.DateTimeFormat("en-US", { + hourCycle: "h23", + timeZone: "America/New_York", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date("2014-06-25T04:00:00.123Z")); + const hourCycleSupported = testDateFormatted === "06/25/2014, 00:00:00" || testDateFormatted === "‎06‎/‎25‎/‎2014‎ ‎00‎:‎00‎:‎00"; + + dtfCache[timeZone] = hourCycleSupported + ? new Intl.DateTimeFormat("en-US", { + hourCycle: "h23", + timeZone: timeZone, + year: "numeric", + month: "numeric", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + : new Intl.DateTimeFormat("en-US", { + hour12: false, + timeZone: timeZone, + year: "numeric", + month: "numeric", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } + return dtfCache[timeZone]; +}; + +const typeToPos: { [type in keyof Intl.DateTimeFormatPartTypesRegistry]?: number } = { + year: 0, + month: 1, + day: 2, + hour: 3, + minute: 4, + second: 5, +}; + +const partsOffset = (dtf: Intl.DateTimeFormat, date: Date) => { + try { + const formatted = dtf.formatToParts(date); + const filled: number[] = []; + for (let i = 0; i < formatted.length; i++) { + const pos = typeToPos[formatted[i].type]; + + if (pos !== undefined) { + filled[pos] = parseInt(formatted[i].value, 10); + } + } + return filled; + } catch (error) { + if (error instanceof RangeError) { + return [NaN]; + } + throw error; + } +}; + +const hackyOffset = (dtf: Intl.DateTimeFormat, date: Date) => { + const formatted = dtf.format(date); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const parsed = /(\d+)\/(\d+)\/(\d+),? (\d+):(\d+):(\d+)/.exec(formatted)!; + // const [, fMonth, fDay, fYear, fHour, fMinute, fSecond] = parsed + // return [fYear, fMonth, fDay, fHour, fMinute, fSecond] + return [ + parseInt(parsed[3], 10), + parseInt(parsed[1], 10), + parseInt(parsed[2], 10), + parseInt(parsed[4], 10), + parseInt(parsed[5], 10), + parseInt(parsed[6], 10), + ]; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19b3eff72..20d34a303 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,12 +244,6 @@ importers: '@mui/x-date-pickers': specifier: ^6.14.0 version: 6.20.1(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@5.15.19)(@mui/system@5.15.20)(@types/react@18.2.79)(date-fns@2.30.0)(react-dom@18.2.0)(react@18.2.0) - date-fns: - specifier: ^2.30.0 - version: 2.30.0 - date-fns-tz: - specifier: ^2.0.0 - version: 2.0.1(date-fns@2.30.0) filesize: specifier: ^10.1.2 version: 10.1.2 @@ -395,6 +389,12 @@ importers: cypress-each: specifier: ^1.13.3 version: 1.14.0 + date-fns: + specifier: ^2.30.0 + version: 2.30.0 + date-fns-tz: + specifier: ^2.0.0 + version: 2.0.1(date-fns@2.30.0) execa: specifier: ^5.1.1 version: 5.1.1 @@ -10796,14 +10796,13 @@ packages: date-fns: 2.x dependencies: date-fns: 2.30.0 - dev: false + dev: true /date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} dependencies: '@babel/runtime': 7.24.4 - dev: false /dayjs@1.11.11: resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==}