diff --git a/package.json b/package.json index 87c846eb2af..6bbea3feb11 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,10 @@ "test:ci": "jest --ci --maxWorkers=4 --silent", "test:dev": "jest --detectOpenHandles --coverage=false", "test:tz": "yarn test:tz-eu-rome && yarn test:tz-us-ny && yarn test:tz-us-yt && yarn test:tz-au-syd", - "test:tz-eu-rome": "TZ='Europe/Rome' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date IT|removeTimezoneFromDate should remove the timezone from a date' './ts/utils/__tests__/fiscal-code.test.ts' './ts/utils/__tests__/dates.test.ts'", - "test:tz-us-ny": "TZ='America/New_York' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date NY|removeTimezoneFromDate should remove the timezone from a date' './ts/utils/__tests__/fiscal-code.test.ts' './ts/utils/__tests__/dates.test.ts'", - "test:tz-us-yt": "TZ='America/Whitehorse' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date CA|removeTimezoneFromDate should remove the timezone from a date' './ts/utils/__tests__/fiscal-code.test.ts' './ts/utils/__tests__/dates.test.ts'", - "test:tz-au-syd": "TZ='Australia/Sydney' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date AU|removeTimezoneFromDate should remove the timezone from a date' './ts/utils/__tests__/fiscal-code.test.ts' './ts/utils/__tests__/dates.test.ts'", + "test:tz-eu-rome": "TZ='Europe/Rome' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date IT|SimpleDateClaim should handle valid, invalid, edge cases, and formatting correctly' './ts/utils/__tests__/fiscal-code.test.ts' './ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts'", + "test:tz-us-ny": "TZ='America/New_York' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date NY|SimpleDateClaim should handle valid, invalid, edge cases, and formatting correctly' './ts/utils/__tests__/fiscal-code.test.ts' './ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts'", + "test:tz-us-yt": "TZ='America/Whitehorse' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date CA|SimpleDateClaim should handle valid, invalid, edge cases, and formatting correctly' './ts/utils/__tests__/fiscal-code.test.ts' './ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts'", + "test:tz-au-syd": "TZ='Australia/Sydney' jest --config='./jest.config.no.timezone.js' --detectOpenHandles --coverage=false -t 'Check fiscal code date AU|SimpleDateClaim should handle valid, invalid, edge cases, and formatting correctly' './ts/utils/__tests__/fiscal-code.test.ts' './ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts'", "prettify": "prettier --write \"ts/**/*.(ts|tsx)\"", "prettier:check": "prettier --check \"ts/**/*.(ts|tsx)\"", "packager:clear": "rm -rf $TMPDIR/react-native-packager-cache-* && rm -rf $TMPDIR/metro-bundler-cache-*", diff --git a/ts/features/itwallet/common/components/ItwCredentialClaim.tsx b/ts/features/itwallet/common/components/ItwCredentialClaim.tsx index 19d0ab30927..f3a7613dcca 100644 --- a/ts/features/itwallet/common/components/ItwCredentialClaim.tsx +++ b/ts/features/itwallet/common/components/ItwCredentialClaim.tsx @@ -1,5 +1,4 @@ import { Divider, ListItemInfo } from "@pagopa/io-app-design-system"; -import { DateFromString } from "@pagopa/ts-commons/lib/dates"; import * as E from "fp-ts/Either"; import * as O from "fp-ts/Option"; import { pipe } from "fp-ts/lib/function"; @@ -7,7 +6,6 @@ import React, { useMemo } from "react"; import { Image } from "react-native"; import I18n from "../../../../i18n"; import { useIOBottomSheetAutoresizableModal } from "../../../../utils/hooks/bottomSheet"; -import { localeDateFormat } from "../../../../utils/locale"; import { useItwInfoBottomSheet } from "../hooks/useItwInfoBottomSheet"; import { BoolClaim, @@ -17,15 +15,17 @@ import { DrivingPrivilegesClaim, EmptyStringClaim, EvidenceClaim, + extractFiscalCode, FiscalCodeClaim, + getSafeText, ImageClaim, + isExpirationDateClaim, PdfClaim, PlaceOfBirthClaim, PlaceOfBirthClaimType, - StringClaim, - extractFiscalCode, - isExpirationDateClaim, - getSafeText + SimpleDate, + SimpleDateClaim, + StringClaim } from "../utils/itwClaimsUtils"; import { ItwCredentialStatus } from "../utils/itwTypesUtils"; @@ -108,15 +108,12 @@ const DateClaimItem = ({ status }: { label: string; - claim: Date; + claim: SimpleDate; status?: ItwCredentialStatus; }) => { // Remove the timezone offset to display the date in its original format - const value = localeDateFormat( - claim, - I18n.t("global.dateFormats.shortFormat") - ); + const value = claim.toString("DD/MM/YYYY"); const endElement: ListItemInfo["endElement"] = useMemo(() => { const ns = "features.itWallet.presentation.credentialDetails.status"; @@ -270,14 +267,8 @@ const DrivingPrivilegesClaimItem = ({ claim: DrivingPrivilegeClaimType; detailsButtonVisible?: boolean; }) => { - const localExpiryDate = localeDateFormat( - claim.expiry_date, - I18n.t("global.dateFormats.shortFormat") - ); - const localIssueDate = localeDateFormat( - claim.issue_date, - I18n.t("global.dateFormats.shortFormat") - ); + const localExpiryDate = claim.expiry_date.toString("DD/MM/YYYY"); + const localIssueDate = claim.issue_date.toString("DD/MM/YYYY"); const privilegeBottomSheet = useIOBottomSheetAutoresizableModal({ title: I18n.t( "features.itWallet.verifiableCredentials.claims.mdl.category", @@ -371,7 +362,7 @@ export const ItwCredentialClaim = ({ const decoded = hidden ? HIDDEN_CLAIM : _decoded; if (PlaceOfBirthClaim.is(decoded)) { return ; - } else if (DateFromString.is(decoded)) { + } else if (SimpleDateClaim.is(decoded)) { return ( ; @@ -61,7 +61,7 @@ const CardClaim = ({ position, dimensions, testID, - dateFormat = "%d/%m/%Y", + dateFormat = "DD/MM/YY", ...labelProps }: WithTestID) => { const claimContent = React.useMemo( @@ -70,8 +70,8 @@ const CardClaim = ({ claim?.value, ClaimValue.decode, E.fold(constNull, decoded => { - if (DateFromString.is(decoded)) { - const formattedDate = localeDateFormat(decoded, dateFormat); + if (SimpleDateClaim.is(decoded)) { + const formattedDate = decoded.toString(dateFormat); return {formattedDate}; } else if (EvidenceClaim.is(decoded)) { return ( diff --git a/ts/features/itwallet/common/components/ItwSkeumorphicCard/CardData.tsx b/ts/features/itwallet/common/components/ItwSkeumorphicCard/CardData.tsx index 39fb5b078fc..88fce822d10 100644 --- a/ts/features/itwallet/common/components/ItwSkeumorphicCard/CardData.tsx +++ b/ts/features/itwallet/common/components/ItwSkeumorphicCard/CardData.tsx @@ -1,12 +1,10 @@ /* eslint-disable dot-notation */ /* eslint-disable @typescript-eslint/dot-notation */ -import { parse } from "date-fns"; import { pipe } from "fp-ts/lib/function"; import * as O from "fp-ts/lib/Option"; -import { Fragment, default as React } from "react"; +import { default as React, Fragment } from "react"; import { StyleSheet, View } from "react-native"; import { QrCodeImage } from "../../../../../components/QrCodeImage"; -import { localeDateFormat } from "../../../../../utils/locale"; import { DrivingPrivilegesClaim, StringClaim @@ -50,7 +48,7 @@ const MdlFrontData = ({ claims }: DataComponentProps) => { { claim={claims["issue_date"]} position={{ left: `${cols[0]}%`, top: `${rows[3]}%` }} fontWeight={"Bold"} + dateFormat={"DD/MM/YYYY"} /> { claim={claims["expiry_date"]} position={{ left: `${cols[0]}%`, top: `${rows[4]}%` }} fontWeight={"Bold"} + dateFormat={"DD/MM/YYYY"} /> { }} > - {localeDateFormat(parse(issue_date), "%d/%m/%y")} + {issue_date.toString("DD/MM/YY")} { }} > - {localeDateFormat(parse(expiry_date), "%d/%m/%y")} + {expiry_date.toString("DD/MM/YY")} {restrictions_conditions && ( diff --git a/ts/features/itwallet/common/components/ItwSkeumorphicCard/__tests__/__snapshots__/CardData.test.tsx.snap b/ts/features/itwallet/common/components/ItwSkeumorphicCard/__tests__/__snapshots__/CardData.test.tsx.snap index 5dd40cf4d79..bd715f5075b 100644 --- a/ts/features/itwallet/common/components/ItwSkeumorphicCard/__tests__/__snapshots__/CardData.test.tsx.snap +++ b/ts/features/itwallet/common/components/ItwSkeumorphicCard/__tests__/__snapshots__/CardData.test.tsx.snap @@ -242,7 +242,7 @@ exports[`CardData should match snapshot for DC front data 1`] = ` } weight="Semibold" > - 15/03/1990 + 15/03/90 - 23/03/2032 + 23/03/32 diff --git a/ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts b/ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts index f9c94d536cb..a3174e5e44f 100644 --- a/ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts +++ b/ts/features/itwallet/common/utils/__tests__/itwClaimsUtils.test.ts @@ -7,7 +7,8 @@ import { getCredentialExpireDays, getCredentialStatus, getFiscalCodeFromCredential, - ImageClaim + ImageClaim, + SimpleDateClaim } from "../itwClaimsUtils"; import { StoredCredential } from "../itwTypesUtils"; import { ItwStoredCredentialsMocks } from "../itwMocksUtils"; @@ -468,3 +469,55 @@ describe("getCredentialStatus", () => { }); }); }); + +describe("SimpleDateClaim", () => { + it("should handle valid, invalid, edge cases, and formatting correctly", () => { + // Valid date decoding + const validInput = "2024-11-19"; + const validResult = SimpleDateClaim.decode(validInput); + expect(E.isRight(validResult)).toBe(true); + if (E.isRight(validResult)) { + const decodedDate = validResult.right; + + // Validate date parts + expect(decodedDate.getFullYear()).toBe(2024); + expect(decodedDate.getMonth()).toBe(10); // 0-indexed month + expect(decodedDate.getDate()).toBe(19); + + // Validate default and custom formats + expect(decodedDate.toString()).toBe("19/11/2024"); + expect(decodedDate.toString("DD/MM/YY")).toBe("19/11/24"); + + // Validate Date object conversions + expect(decodedDate.toDate()).toEqual(new Date(2024, 10, 19)); + expect(decodedDate.toDateWithoutTimezone().toISOString()).toBe( + "2024-11-19T00:00:00.000Z" + ); + } + + // Invalid date decoding + const invalidInput = "invalid-date"; + const invalidResult = SimpleDateClaim.decode(invalidInput); + expect(E.isLeft(invalidResult)).toBe(true); + + // Valid leap year date + const leapYearInput = "2024-02-29"; + const leapYearResult = SimpleDateClaim.decode(leapYearInput); + expect(E.isRight(leapYearResult)).toBe(true); + if (E.isRight(leapYearResult)) { + const leapYearDate = leapYearResult.right; + expect(leapYearDate.getFullYear()).toBe(2024); + expect(leapYearDate.getMonth()).toBe(1); // 0-indexed month + expect(leapYearDate.getDate()).toBe(29); + } + + // Valid date with padded spaces + const paddedInput = " 2024-11-19 "; + const paddedResult = SimpleDateClaim.decode(paddedInput.trim()); + expect(E.isRight(paddedResult)).toBe(true); + if (E.isRight(paddedResult)) { + const paddedDate = paddedResult.right; + expect(paddedDate.toString()).toBe("19/11/2024"); + } + }); +}); diff --git a/ts/features/itwallet/common/utils/itwClaimsUtils.ts b/ts/features/itwallet/common/utils/itwClaimsUtils.ts index 4b336d98533..655b1bd03a0 100644 --- a/ts/features/itwallet/common/utils/itwClaimsUtils.ts +++ b/ts/features/itwallet/common/utils/itwClaimsUtils.ts @@ -2,7 +2,6 @@ * Utility functions for working with credential claims. */ -import { patternDateFromString } from "@pagopa/ts-commons/lib/dates"; import { NonEmptyString, PatternString } from "@pagopa/ts-commons/lib/strings"; import { differenceInCalendarDays, isValid } from "date-fns"; import { pipe } from "fp-ts/lib/function"; @@ -12,12 +11,11 @@ import * as E from "fp-ts/lib/Either"; import { truncate } from "lodash"; import { Locales } from "../../../../../locales/locales"; import I18n from "../../../../i18n"; -import { removeTimezoneFromDate } from "../../../../utils/dates"; import { JsonFromString } from "./ItwCodecUtils"; import { + ItwCredentialStatus, ParsedCredential, - StoredCredential, - ItwCredentialStatus + StoredCredential } from "./itwTypesUtils"; /** @@ -106,6 +104,81 @@ export const parseClaims = ( * */ +export const SimpleDateFormat = { + DDMMYYYY: "DD/MM/YYYY", + DDMMYY: "DD/MM/YY" +} as const; + +export type SimpleDateFormat = + (typeof SimpleDateFormat)[keyof typeof SimpleDateFormat]; + +/** + * A simpler Date class with day, month and year properties + * It simplifies dates handling by removing Date overhead + * @property year - the year + * @property month - the month (0-11) + * @property day - the day (1-31) + * @function toDate - returns a Date object + * @function toString - returns a string in the format "DD/MM/YYYY" + */ +export class SimpleDate { + private year: number; + private month: number; + private day: number; + + constructor(year: number, month: number, day: number) { + this.year = year; + this.month = month; + this.day = day; + } + + /** + * Returns a string in the format specified by the format parameter + */ + toString(format: SimpleDateFormat = "DD/MM/YYYY"): string { + const dayString = this.day.toString().padStart(2, "0"); + const monthString = (this.month + 1).toString().padStart(2, "0"); + const yearString = this.year.toString(); + return format + .replace("DD", dayString) + .replace("MM", monthString) + .replace("YYYY", yearString) + .replace("YY", yearString.slice(-2)); + } + + /** + * Returns a Date object + */ + toDate(): Date { + return new Date(this.year, this.month, this.day); + } + + toDateWithoutTimezone(): Date { + return new Date(Date.UTC(this.year, this.month, this.day)); + } + + /** + * Returns the year + */ + getFullYear(): number { + return this.year; + } + + /** + * Returns the month (0-11) + */ + getMonth(): number { + return this.month; + } + + /** + * Returns the day (1-31) + */ + getDate(): number { + return this.day; + } +} + /** * Enum for the claims locales. * This is used to get the correct locale for the claims. @@ -173,27 +246,31 @@ const FISCAL_CODE_WITH_PREFIX = * The date format is checked against the regex dateFormatRegex, which is currenlty mocked. * This is needed because a generic date decoder would accept invalid dates like numbers, * thus decoding properly and returning a wrong claim item to be displayed. - * It also removes the timezone from the date given that the date must be displayed regardless of the timezone of the device. + * The returned date is a SimpleDate object, which is a simpler date class with day, month and year properties. */ -export const DateWithoutTimezoneClaim = new t.Type( - "DateWithoutTimezone", - (input: unknown): input is Date => input instanceof Date, +export const SimpleDateClaim = new t.Type( + "SimpleDateClaim", + (input: unknown): input is SimpleDate => input instanceof SimpleDate, (input, context) => pipe( - patternDateFromString(DATE_FORMAT_REGEX, "DateClaim").validate( - input, - context - ), + PatternString(DATE_FORMAT_REGEX).validate(input, context), E.fold( () => t.failure(input, context, "Date is not in the correct format"), str => { - const date = new Date(str); - return t.success(removeTimezoneFromDate(date)); + const date = new SimpleDate( + +str.slice(0, 4), + +str.slice(5, 7) - 1, + +str.slice(8, 10) + ); + return t.success(date); } ) ), - (date: Date) => - `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}` + (date: SimpleDate) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + "0" + )}-${String(date.getDate()).padStart(2, "0")}` ); /** @@ -227,8 +304,8 @@ export type PlaceOfBirthClaimType = t.TypeOf; */ const DrivingPrivilegeClaim = t.type({ driving_privilege: t.string, - issue_date: DateWithoutTimezoneClaim, - expiry_date: DateWithoutTimezoneClaim, + issue_date: SimpleDateClaim, + expiry_date: SimpleDateClaim, restrictions_conditions: t.union([t.string, t.null]) }); @@ -295,8 +372,8 @@ export const ClaimValue = t.union([ DrivingPrivilegesClaim, // Parse an object representing the claim evidence EvidenceClaim, - // Otherwise parse a date - DateWithoutTimezoneClaim, + // Otherwise parse a date as string + SimpleDateClaim, // Otherwise parse an image ImageClaim, // Otherwise parse a PDF diff --git a/ts/features/itwallet/issuance/components/ItwRequiredClaimsList.tsx b/ts/features/itwallet/issuance/components/ItwRequiredClaimsList.tsx index 0c60e3b2c89..93c38ceb118 100644 --- a/ts/features/itwallet/issuance/components/ItwRequiredClaimsList.tsx +++ b/ts/features/itwallet/issuance/components/ItwRequiredClaimsList.tsx @@ -12,11 +12,10 @@ import React from "react"; import { StyleSheet, View } from "react-native"; import * as O from "fp-ts/Option"; import I18n from "../../../../i18n"; -import { localeDateFormat } from "../../../../utils/locale"; import { + BoolClaim, ClaimDisplayFormat, ClaimValue, - DateWithoutTimezoneClaim, DrivingPrivilegesClaim, EmptyStringClaim, EvidenceClaim, @@ -24,8 +23,8 @@ import { FiscalCodeClaim, getSafeText, ImageClaim, - BoolClaim, PlaceOfBirthClaim, + SimpleDateClaim, StringClaim } from "../../common/utils/itwClaimsUtils"; import { isStringNullyOrEmpty } from "../../../../utils/strings"; @@ -94,11 +93,8 @@ export const getClaimDisplayValue = ( decoded => { if (PlaceOfBirthClaim.is(decoded)) { return `${decoded.locality} (${decoded.country})`; - } else if (DateWithoutTimezoneClaim.is(decoded)) { - return localeDateFormat( - decoded, - I18n.t("global.dateFormats.shortFormat") - ); + } else if (SimpleDateClaim.is(decoded)) { + return decoded.toString(); } else if (EvidenceClaim.is(decoded)) { return decoded[0].record.source.organization_name; } else if (ImageClaim.is(decoded)) { diff --git a/ts/utils/__tests__/dates.test.ts b/ts/utils/__tests__/dates.test.ts index ede77c2a4e7..14d78bb135a 100644 --- a/ts/utils/__tests__/dates.test.ts +++ b/ts/utils/__tests__/dates.test.ts @@ -5,8 +5,7 @@ import { getDateFromExpiryDate, getExpireStatus, isExpired, - isExpiredDate, - removeTimezoneFromDate + isExpiredDate } from "../dates"; describe("getExpireStatus", () => { @@ -114,18 +113,3 @@ describe("getDateFromExpiryDate", () => { expect(isExpiredDate(date!)).toEqual(true); }); }); - -describe("removeTimezoneFromDate", () => { - it("should remove the timezone from a date", () => { - const date = new Date("2023-02-01"); - const dateWithoutTimezone = removeTimezoneFromDate(date); - expect(dateWithoutTimezone.getDate()).toBe(1); - expect(dateWithoutTimezone.getMonth()).toBe(1); // Month is zero based - expect(dateWithoutTimezone.getFullYear()).toBe(2023); - }); - - it("should throw if the date is invalid", () => { - const date = new Date("invalid-date"); - expect(() => removeTimezoneFromDate(date)).toThrow(Error); - }); -}); diff --git a/ts/utils/dates.ts b/ts/utils/dates.ts index be2227cec63..949ba10b71d 100644 --- a/ts/utils/dates.ts +++ b/ts/utils/dates.ts @@ -307,16 +307,3 @@ export const getDateFromExpiryDate = (expiryDate: string): Date | undefined => { return undefined; } }; - -/** - * Remove timezone from a date. - * @param date - the date to remove timezone from - * @returns a new date with the timezone removed - */ -export const removeTimezoneFromDate = (date: Date) => { - if (isNaN(date.getTime())) { - throw new Error("Invalid date"); - } - const userTimezoneOffset = date.getTimezoneOffset() * 60000; - return new Date(date.getTime() + userTimezoneOffset); -};