diff --git a/apps/studio/.storybook/preview.tsx b/apps/studio/.storybook/preview.tsx index c5fa005be..68763c3da 100644 --- a/apps/studio/.storybook/preview.tsx +++ b/apps/studio/.storybook/preview.tsx @@ -162,6 +162,7 @@ export const MockDateDecorator: Decorator = (story, { parameters }) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument mockdate.set(parameters.mockdate) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const mockedDate = format(parameters.mockdate, "dd-mm-yyyy HH:mma") return ( diff --git a/apps/studio/package.json b/apps/studio/package.json index 7a0c41390..d61bc1798 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -106,7 +106,7 @@ "@trpc/react-query": "10.45.0", "@trpc/server": "10.45.0", "ajv": "^8.16.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "date-fns-tz": "^3.1.3", "flat": "^6.0.1", "fuzzysort": "^2.0.4", diff --git a/package-lock.json b/package-lock.json index a977d71f3..ae7a6b2c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "@trpc/react-query": "10.45.0", "@trpc/server": "10.45.0", "ajv": "^8.16.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "date-fns-tz": "^3.1.3", "flat": "^6.0.1", "fuzzysort": "^2.0.4", @@ -381,6 +381,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "apps/studio/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "apps/studio/node_modules/eslint": { "version": "9.10.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.10.0.tgz", @@ -16002,6 +16011,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -29567,7 +29577,7 @@ "@govtechsg/sgds-react": "^2.5.1", "@headlessui/react": "^2.1.2", "@sinclair/typebox": "^0.33.12", - "dayjs": "^1.11.13", + "date-fns": "^4.1.0", "interweave": "^13.1.0", "interweave-ssr": "^2.0.0", "isomorphic-dompurify": "^2.12.0", @@ -29800,6 +29810,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "packages/components/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "packages/components/node_modules/eslint": { "version": "9.10.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.10.0.tgz", diff --git a/packages/components/package.json b/packages/components/package.json index e42f62168..a04757c7a 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -96,7 +96,7 @@ "@govtechsg/sgds-react": "^2.5.1", "@headlessui/react": "^2.1.2", "@sinclair/typebox": "^0.33.12", - "dayjs": "^1.11.13", + "date-fns": "^4.1.0", "interweave": "^13.1.0", "interweave-ssr": "^2.0.0", "isomorphic-dompurify": "^2.12.0", diff --git a/packages/components/src/templates/next/layouts/Collection/Collection.tsx b/packages/components/src/templates/next/layouts/Collection/Collection.tsx index 202715c2b..32a7d85ab 100644 --- a/packages/components/src/templates/next/layouts/Collection/Collection.tsx +++ b/packages/components/src/templates/next/layouts/Collection/Collection.tsx @@ -1,8 +1,4 @@ -import type { - CollectionPageSchemaType, - IsomerSitemap, - IsomerSiteProps, -} from "~/engine" +import type { CollectionPageSchemaType, IsomerSiteProps } from "~/engine" import type { CollectionCardProps } from "~/interfaces" import { getBreadcrumbFromSiteMap, getSitemapAsArray } from "~/utils" import { Skeleton } from "../Skeleton" @@ -49,13 +45,8 @@ const getCollectionItems = ( item.layout === "article", ) .map((item) => { - const date = new Date(item.date || item.lastModified) - const lastUpdated = - date.getDate().toString().padStart(2, "0") + - " " + - date.toLocaleString("default", { month: "long" }) + - " " + - date.getFullYear() + const lastUpdated = item.date || item.lastModified + const date = new Date(lastUpdated) const baseItem = { type: "collectionCard" as const, diff --git a/packages/components/src/templates/next/layouts/Collection/utils.ts b/packages/components/src/templates/next/layouts/Collection/utils.ts index 68edd3d88..257ab71a1 100644 --- a/packages/components/src/templates/next/layouts/Collection/utils.ts +++ b/packages/components/src/templates/next/layouts/Collection/utils.ts @@ -1,5 +1,6 @@ import type { AppliedFilter, Filter as FilterType } from "../../types/Filter" import type { CollectionCardProps } from "~/interfaces" +import { getParsedDate } from "~/utils" export const getAvailableFilters = ( items: CollectionCardProps[], @@ -25,7 +26,7 @@ export const getAvailableFilters = ( // Step 3: Get all available years if (lastUpdated) { - const year = new Date(lastUpdated).getFullYear().toString() + const year = getParsedDate(lastUpdated).getFullYear().toString() if (year in years && years[year]) { years[year] += 1 } else { diff --git a/packages/components/src/types/utils.ts b/packages/components/src/types/utils.ts index 0b2abfc6d..4a3544dd1 100644 --- a/packages/components/src/types/utils.ts +++ b/packages/components/src/types/utils.ts @@ -1,5 +1,10 @@ +import type { Tagged } from "type-fest" + export type ValueOf = T[keyof T] +// This is a branded type for a formatted date string using getFormattedDate +export type FormattedDate = Tagged + // This is the Next.js Link component that resembles the HTML anchor tag export type LinkComponentType = any diff --git a/packages/components/src/utils/getFormattedDate.ts b/packages/components/src/utils/getFormattedDate.ts index 96ff2cc6d..01421924e 100644 --- a/packages/components/src/utils/getFormattedDate.ts +++ b/packages/components/src/utils/getFormattedDate.ts @@ -1,13 +1,15 @@ -import dayjs from "dayjs" -import customParseFormat from "dayjs/plugin/customParseFormat" +import { format } from "date-fns" -dayjs.extend(customParseFormat) +import type { FormattedDate } from "~/types" +import { getParsedDate } from "./getParsedDate" // Standardise the format of dates displayed on the site -export const getFormattedDate = (date: string) => - dayjs(date, [ - "DD/MM/YYYY", - "D MMM YYYY", - "DD MMM YYYY", - "YYYY-MM-DDTHH:mm:ss.SSSZ", - ]).format("D MMMM YYYY") +export const getFormattedDate = (dateString?: string): FormattedDate => { + if (dateString === undefined) { + return format(new Date(), "d MMMM yyyy") as FormattedDate + } + + const date = getParsedDate(dateString) + + return format(date, "d MMMM yyyy") as FormattedDate +} diff --git a/packages/components/src/utils/getParsedDate.ts b/packages/components/src/utils/getParsedDate.ts new file mode 100644 index 000000000..f7e916d9f --- /dev/null +++ b/packages/components/src/utils/getParsedDate.ts @@ -0,0 +1,39 @@ +import { isMatch, parse } from "date-fns" + +const SUPPORTED_DATE_FORMATS = [ + "dd/MM/yyyy", + "d MMM yyyy", + "d MMMM yyyy", + "dd MMM yyyy", + "dd MMMM yyyy", + "yyyy-MM-dd", + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", +] + +export const getParsedDate = (dateString: string) => { + const parsedDate = SUPPORTED_DATE_FORMATS.reduce( + (acc, format) => { + if (acc) { + // Date has already been parsed by an earlier format + return acc + } + + try { + if (isMatch(dateString, format)) { + return parse(dateString, format, new Date()) + } + } catch (e) { + return new Date() + } + + return acc + }, + undefined, + ) + + if (parsedDate) { + return parsedDate + } + + return new Date() +} diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index ade80f5e9..856fb0669 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -1,6 +1,7 @@ export { getBreadcrumbFromSiteMap } from "./getBreadcrumbFromSiteMap" export { getDigestFromText } from "./getDigestFromText" export { getFormattedDate } from "./getFormattedDate" +export { getParsedDate } from "./getParsedDate" export { getRandomNumberBetIntervals } from "./getRandomNumber" export { getReferenceLinkHref } from "./getReferenceLinkHref" export { getSanitizedIframeWithTitle } from "./getSanitizedIframeWithTitle"