diff --git a/web/src/beta/ui/fields/TimePointField/EditPanel/hooks.ts b/web/src/beta/ui/fields/TimePointField/EditPanel/hooks.ts index 082bef65b6..bffac027df 100644 --- a/web/src/beta/ui/fields/TimePointField/EditPanel/hooks.ts +++ b/web/src/beta/ui/fields/TimePointField/EditPanel/hooks.ts @@ -1,7 +1,9 @@ import { + type ParsedDateTime, getLocalTimezoneOffset, isValidDateTimeFormat, isValidTimezone, + parseDateTime, TimeZoneOffset } from "@reearth/beta/utils/time"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -42,8 +44,9 @@ export default ({ value, onChange, onClose }: Props) => { useEffect(() => { if (value && isValidDateTimeFormat(value)) { - const [parsedDate, timeWithOffset] = value.split("T"); - const [parsedTime, timezoneOffset] = timeWithOffset.split(/[-+]/); + //Since isValidDateTimeFormat already validates the input, it's safe to assert the type as ParsedDateTime. + const { parsedDate, timeWithOffset, parsedTime, timezoneOffset } = + parseDateTime(value) as ParsedDateTime; setDate(parsedDate); setTime(parsedTime); diff --git a/web/src/beta/ui/fields/TimePointField/index.tsx b/web/src/beta/ui/fields/TimePointField/index.tsx index e1a84c3a9d..ea31d84c6d 100644 --- a/web/src/beta/ui/fields/TimePointField/index.tsx +++ b/web/src/beta/ui/fields/TimePointField/index.tsx @@ -1,4 +1,5 @@ import { Button, Popup, TextInput } from "@reearth/beta/lib/reearth-ui"; +import { isValidDateTimeFormat } from "@reearth/beta/utils/time"; import { useT } from "@reearth/services/i18n"; import { styled, useTheme } from "@reearth/services/theme"; import { FC, useCallback, useEffect, useState } from "react"; @@ -44,7 +45,9 @@ const TimePointField: FC = ({ (timeString: string) => { if (timeString === value) return; // TODO: validate timeString - onChange?.(timeString); + if (timeString && isValidDateTimeFormat(timeString)) { + onChange?.(timeString); + } }, [value, onChange] ); diff --git a/web/src/beta/utils/time.test.ts b/web/src/beta/utils/time.test.ts index decb14c17b..c3d00867f4 100644 --- a/web/src/beta/utils/time.test.ts +++ b/web/src/beta/utils/time.test.ts @@ -1,6 +1,6 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, it } from "vitest"; -import { formatRelativeTime } from "./time"; +import { formatRelativeTime, parseDateTime } from "./time"; describe("formatRelativeTime", () => { const now = new Date(); @@ -73,3 +73,76 @@ describe("formatRelativeTime", () => { expect(formatRelativeTime(date, "ja")).toBe("2年前"); }); }); + +describe("parseDateTime", () => { + it.each([ + [ + "2024-01-01T12:00Z", + { + parsedDate: "2024-01-01", + timeWithOffset: "12:00Z", + parsedTime: "12:00", + timezoneOffset: "00:00" + } + ], + [ + "2024-01-01T12:00:00Z", + { + parsedDate: "2024-01-01", + timeWithOffset: "12:00:00Z", + parsedTime: "12:00:00", + timezoneOffset: "00:00" + } + ], + [ + "2024-01-01T12:00:00.123Z", + { + parsedDate: "2024-01-01", + timeWithOffset: "12:00:00.123Z", + parsedTime: "12:00:00.123", + timezoneOffset: "00:00" + } + ], + [ + "2024-01-01T12:00+09:00", + { + parsedDate: "2024-01-01", + timeWithOffset: "12:00+09:00", + parsedTime: "12:00", + timezoneOffset: "09:00" + } + ], + [ + "2024-01-01T12:00:00+09:00", + { + parsedDate: "2024-01-01", + timeWithOffset: "12:00:00+09:00", + parsedTime: "12:00:00", + timezoneOffset: "09:00" + } + ], + [ + "2024-01-01T12:00:00.123+09:00", + { + parsedDate: "2024-01-01", + timeWithOffset: "12:00:00.123+09:00", + parsedTime: "12:00:00.123", + timezoneOffset: "09:00" + } + ] + ])("should correctly parse %s", (input, expected) => { + const result = parseDateTime(input); + expect(result).toEqual(expected); + }); + + it.each([ + "invalid", + "2024-01-01", + "2024-01-01T12", + "2024-01-01T12:00+9:00", + "" + ])("should return null for invalid datetime format %s", (input) => { + const result = parseDateTime(input); + expect(result).toBeNull(); + }); +}); diff --git a/web/src/beta/utils/time.ts b/web/src/beta/utils/time.ts index 08f12e6022..e402e40139 100644 --- a/web/src/beta/utils/time.ts +++ b/web/src/beta/utils/time.ts @@ -111,5 +111,37 @@ export const getTimeZone = (time: string): TimeZoneOffset | undefined => { }; export const isValidDateTimeFormat = (time: string): boolean => { - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([-+]\d{2}:\d{2})$/.test(time); + return /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?)(Z|([+-]\d{2}:\d{2}))?$/.test( + time + ); +}; + +export type ParsedDateTime = { + parsedDate: string; + timeWithOffset: string; + parsedTime: string; + timezoneOffset: string; +}; + +export const parseDateTime = (value: string): ParsedDateTime | null => { + const match = value.match( + /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?)(Z|([+-]\d{2}:\d{2}))?$/ + ); + + if (!match) { + return null; + } + + const parsedDate = match[1]; + const timeWithOffset = match[2] + (match[3] || ""); + const parsedTime = match[2]; + const timezoneOffset = + match[3] === "Z" ? "00:00" : match[3]?.replace("+", "") || "00:00"; + + return { + parsedDate, + timeWithOffset, + parsedTime, + timezoneOffset + }; };