diff --git a/package-lock.json b/package-lock.json index 66e201401..2ed3c4868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,8 +36,8 @@ "apisauce": "^3.0.1", "axios": "^1.7.4", "classnames": "^2.5.1", - "date-fns": "^2.30.0", - "date-fns-tz": "^2.0.1", + "date-fns": "^3.0.0", + "date-fns-tz": "^3.0.0", "i18next": "^23.10.1", "i18next-fluent": "^2.0.0", "i18next-resources-to-backend": "^1.2.0", @@ -8752,26 +8752,20 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/date-fns-tz": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz", - "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", "peerDependencies": { - "date-fns": "2.x" + "date-fns": "^3.0.0 || ^4.0.0" } }, "node_modules/dayjs": { diff --git a/package.json b/package.json index c7100e284..9d0fc8fdd 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,8 @@ "apisauce": "^3.0.1", "axios": "^1.7.4", "classnames": "^2.5.1", - "date-fns": "^2.30.0", - "date-fns-tz": "^2.0.1", + "date-fns": "^3.0.0", + "date-fns-tz": "^3.0.0", "i18next": "^23.10.1", "i18next-fluent": "^2.0.0", "i18next-resources-to-backend": "^1.2.0", diff --git a/patches/date-fns-tz+3.2.0.patch b/patches/date-fns-tz+3.2.0.patch new file mode 100644 index 000000000..9d04967a5 --- /dev/null +++ b/patches/date-fns-tz+3.2.0.patch @@ -0,0 +1,21 @@ +diff --git a/node_modules/date-fns-tz/dist/cjs/_lib/tzIntlTimeZoneName/index.js b/node_modules/date-fns-tz/dist/cjs/_lib/tzIntlTimeZoneName/index.js +index 2211eee..c618343 100644 +--- a/node_modules/date-fns-tz/dist/cjs/_lib/tzIntlTimeZoneName/index.js ++++ b/node_modules/date-fns-tz/dist/cjs/_lib/tzIntlTimeZoneName/index.js +@@ -16,10 +16,12 @@ function tzIntlTimeZoneName(length, date, options) { + exports.tzIntlTimeZoneName = tzIntlTimeZoneName; + function partsTimeZone(dtf, date) { + const formatted = dtf.formatToParts(date); +- for (let i = formatted.length - 1; i >= 0; --i) { +- if (formatted[i].type === 'timeZoneName') { +- return formatted[i].value; +- } ++ // Hack around https://github.com/facebook/hermes/issues/1601 and ++ // https://github.com/marnusw/date-fns-tz/issues/306 by assuming all ++ // parts after timeZoneName are actually a part of timeZoneName ++ const idx = formatted.map(part => part.type).indexOf('timeZoneName'); ++ if ( idx ) { ++ return formatted.slice(idx, formatted.length).map(part => part.value).join( "" ); + } + return undefined; + } diff --git a/src/components/ObsDetails/ActivityTab/ActivityHeader.js b/src/components/ObsDetails/ActivityTab/ActivityHeader.js index e32ca006c..61ff69e9c 100644 --- a/src/components/ObsDetails/ActivityTab/ActivityHeader.js +++ b/src/components/ObsDetails/ActivityTab/ActivityHeader.js @@ -132,11 +132,13 @@ const ActivityHeader = ( { {item.created_at && ( )} diff --git a/src/components/ObsDetails/DetailsTab/DetailsTab.js b/src/components/ObsDetails/DetailsTab/DetailsTab.js index 55ac19902..65c382dec 100644 --- a/src/components/ObsDetails/DetailsTab/DetailsTab.js +++ b/src/components/ObsDetails/DetailsTab/DetailsTab.js @@ -138,7 +138,12 @@ const DetailsTab = ( { currentUser, observation }: Props ): Node => { )} diff --git a/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityHeader.js b/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityHeader.js index e32ca006c..61ff69e9c 100644 --- a/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityHeader.js +++ b/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityHeader.js @@ -132,11 +132,13 @@ const ActivityHeader = ( { {item.created_at && ( )} diff --git a/src/components/ObsDetailsDefaultMode/LocationSection/LocationSection.tsx b/src/components/ObsDetailsDefaultMode/LocationSection/LocationSection.tsx index 40d6e0588..762a88d2c 100644 --- a/src/components/ObsDetailsDefaultMode/LocationSection/LocationSection.tsx +++ b/src/components/ObsDetailsDefaultMode/LocationSection/LocationSection.tsx @@ -8,14 +8,14 @@ import { import { View } from "components/styledComponents"; import { t } from "i18next"; import React from "react"; -import Observation from "realmModels/Observation"; +import type { RealmObservation } from "realmModels/types"; import { useCurrentUser } from "sharedHooks"; import ObscurationExplanation from "./ObscurationExplanation"; interface Props { belongsToCurrentUser: boolean, - observation: Observation, + observation: RealmObservation, handleLocationPickerPressed?: ( ) => void } @@ -55,6 +55,7 @@ const LocationSection = ( { maxFontSizeMultiplier={1} hideIcon textComponent={List2} + timeZone={observation.observed_time_zone} /> )} diff --git a/src/components/ObsDetailsDefaultMode/ObserverDetails.js b/src/components/ObsDetailsDefaultMode/ObserverDetails.js index 8e9feee19..ed9d09c34 100644 --- a/src/components/ObsDetailsDefaultMode/ObserverDetails.js +++ b/src/components/ObsDetailsDefaultMode/ObserverDetails.js @@ -40,6 +40,7 @@ const ObserverDetails = ( { taxonGeoprivacy={taxonGeoprivacy} belongsToCurrentUser={belongsToCurrentUser} maxFontSizeMultiplier={1} + timeZone={observation.observed_time_zone} /> )} diff --git a/src/components/ObsEdit/DatePicker.tsx b/src/components/ObsEdit/DatePicker.tsx index 88b3486c6..89d4b5c40 100644 --- a/src/components/ObsEdit/DatePicker.tsx +++ b/src/components/ObsEdit/DatePicker.tsx @@ -1,17 +1,16 @@ import { Body2, DateTimePicker, INatIcon } from "components/SharedComponents"; import { Pressable, View } from "components/styledComponents"; import React, { useState } from "react"; +import type { RealmObservationPojo } from "realmModels/types"; import { formatISONoSeconds, + formatLongDate, formatLongDatetime } from "sharedHelpers/dateAndTime.ts"; import useTranslation from "sharedHooks/useTranslation"; interface Props { - currentObservation: { - observed_on_string: string; - time_observed_at: string; - }; + currentObservation: RealmObservationPojo; updateObservationKeys: ( { observed_on_string }: { observed_on_string: string } ) => void; } @@ -23,7 +22,9 @@ const DatePicker = ( { currentObservation, updateObservationKeys }: Props ) => { const closeModal = () => setShowModal( false ); const observationDate = currentObservation?.observed_on_string - || currentObservation?.time_observed_at; + || currentObservation?.time_observed_at + || currentObservation?.observed_on + || null; const handlePicked = ( value: Date ) => { const dateString = formatISONoSeconds( value ); @@ -33,13 +34,21 @@ const DatePicker = ( { currentObservation, updateObservationKeys }: Props ) => { closeModal(); }; - const displayDate = ( ) => formatLongDatetime( - observationDate, - i18n, - { missing: null } - ); + const displayDate = ( ) => { + const opts = { + literalTime: !currentObservation?.observed_time_zone, + timeZone: currentObservation?.observed_time_zone, + missing: null + }; + if ( String( observationDate ).includes( "T" ) ) { + return formatLongDatetime( observationDate, i18n, opts ); + } + return formatLongDate( observationDate, i18n, opts ); + }; - const observationDateForPicker = new Date( observationDate ); + const observationDateForPicker = observationDate + ? new Date( observationDate ) + : new Date( ); return ( <> diff --git a/src/components/ObsEdit/EvidenceSectionContainer.js b/src/components/ObsEdit/EvidenceSectionContainer.js index ce00083e3..6ec8324b5 100644 --- a/src/components/ObsEdit/EvidenceSectionContainer.js +++ b/src/components/ObsEdit/EvidenceSectionContainer.js @@ -90,12 +90,13 @@ const EvidenceSectionContainer = ( { ] ); const hasValidDate = useMemo( ( ) => { - const observationDate = parseISO( - currentObservation?.observed_on_string || currentObservation?.time_observed_at + const observationDateString = ( + currentObservation?.observed_on_string + || currentObservation?.time_observed_at ); - if ( observationDate - && !isFuture( observationDate ) - && differenceInCalendarYears( observationDate, new Date( ) ) <= 130 + if ( observationDateString + && !isFuture( parseISO( observationDateString ) ) + && differenceInCalendarYears( parseISO( observationDateString ), new Date( ) ) <= 130 ) { return true; } diff --git a/src/components/ObservationsFlashList/ObsListItem.js b/src/components/ObservationsFlashList/ObsListItem.js index 69812b521..f92c565a3 100644 --- a/src/components/ObservationsFlashList/ObsListItem.js +++ b/src/components/ObservationsFlashList/ObsListItem.js @@ -122,7 +122,9 @@ const ObsListItem = ( { classNameMargin="mt-1" geoprivacy={geoprivacy} taxonGeoprivacy={taxonGeoprivacy} + timeZone={observation.observed_time_zone} belongsToCurrentUser={belongsToCurrentUser} + literalTime={!observation.observed_time_zone} /> )} diff --git a/src/components/SharedComponents/DateDisplay.js b/src/components/SharedComponents/DateDisplay.js index c543cbd70..0e3bb968e 100644 --- a/src/components/SharedComponents/DateDisplay.js +++ b/src/components/SharedComponents/DateDisplay.js @@ -4,31 +4,46 @@ import { Body4, INatIcon } from "components/SharedComponents"; import { View } from "components/styledComponents"; import type { Node } from "react"; import React, { useMemo } from "react"; -import { formatApiDatetime, formatMonthYearDate } from "sharedHelpers/dateAndTime.ts"; +import { + formatApiDatetime, + formatDifferenceForHumans, + formatMonthYearDate +} from "sharedHelpers/dateAndTime.ts"; import { useTranslation } from "sharedHooks"; type Props = { - label?: string, - dateString: string, - classNameMargin?: string, - geoprivacy?: string, - taxonGeoprivacy?: string, + // Display the date as a difference, or relative date, e.g. "1d" or "3w" + asDifference?: boolean, belongsToCurrentUser?: boolean, - maxFontSizeMultiplier?: number, + classNameMargin?: string, + dateString: string, + geoprivacy?: string, hideIcon?: boolean, + label?: string, + // Display the time as literally expressed in the dateString, i.e. don't + // assume it's in any time zone + literalTime?: boolean, + maxFontSizeMultiplier?: number, + taxonGeoprivacy?: string, textComponent?: Function, + // Convert the time to the this time zone; otherwise display in the + // current / local time zone + timeZone?: string; }; const DateDisplay = ( { + asDifference, belongsToCurrentUser, classNameMargin, dateString, geoprivacy, - label, - taxonGeoprivacy, hideIcon, + label, + literalTime, + maxFontSizeMultiplier = 2, + taxonGeoprivacy, textComponent: TextComponentProp, - maxFontSizeMultiplier = 2 + timeZone }: Props ): Node => { const { i18n } = useTranslation( ); @@ -37,40 +52,37 @@ const DateDisplay = ( { TextComponent = Body4; } - const obscuredDate = geoprivacy === "obscured" + const dateObscured = geoprivacy === "obscured" || taxonGeoprivacy === "obscured" || geoprivacy === "private" || taxonGeoprivacy === "private"; - const formatDate = useMemo( () => { - if ( !belongsToCurrentUser && obscuredDate ) { + const formattedDate = useMemo( () => { + if ( !belongsToCurrentUser && dateObscured ) { return formatMonthYearDate( dateString, i18n ); } - return formatApiDatetime( dateString, i18n ); + if ( asDifference ) { + return formatDifferenceForHumans( dateString, i18n ); + } + return formatApiDatetime( dateString, i18n, { literalTime, timeZone } ); }, [ + asDifference, belongsToCurrentUser, - obscuredDate, + dateObscured, dateString, - i18n + i18n, + literalTime, + timeZone ] ); - const date = useMemo( ( ) => ( label - ? `${label} ` - : "" ) + formatDate, [formatDate, label] ); - return ( - {!hideIcon && ( - - )} + {!hideIcon && } - {date} + { `${label || ""} ${formattedDate}`.trim( ) } ); diff --git a/src/i18n/l10n/en.ftl b/src/i18n/l10n/en.ftl index 22f485429..06b97d264 100644 --- a/src/i18n/l10n/en.ftl +++ b/src/i18n/l10n/en.ftl @@ -353,9 +353,11 @@ datetime-difference-weeks = { $count }w # Longer datetime, e.g. on ObsEdit # See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format datetime-format-long = Pp +datetime-format-long-with-zone = Pp (zzz) # Shorter datetime, e.g. on comments and IDs # See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format datetime-format-short = M/d/yy h:mm a +datetime-format-short-with-zone = M/d/yy h:mm a zzz # Month of December December = December Default--interface-mode = Default diff --git a/src/i18n/l10n/en.ftl.json b/src/i18n/l10n/en.ftl.json index 612265508..e2440440a 100644 --- a/src/i18n/l10n/en.ftl.json +++ b/src/i18n/l10n/en.ftl.json @@ -193,7 +193,9 @@ "datetime-difference-minutes": "{ $count }m", "datetime-difference-weeks": "{ $count }w", "datetime-format-long": "Pp", + "datetime-format-long-with-zone": "Pp (zzz)", "datetime-format-short": "M/d/yy h:mm a", + "datetime-format-short-with-zone": "M/d/yy h:mm a zzz", "December": "December", "Default--interface-mode": "Default", "DELETE": "DELETE", diff --git a/src/i18n/strings.ftl b/src/i18n/strings.ftl index 22f485429..06b97d264 100644 --- a/src/i18n/strings.ftl +++ b/src/i18n/strings.ftl @@ -353,9 +353,11 @@ datetime-difference-weeks = { $count }w # Longer datetime, e.g. on ObsEdit # See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format datetime-format-long = Pp +datetime-format-long-with-zone = Pp (zzz) # Shorter datetime, e.g. on comments and IDs # See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format datetime-format-short = M/d/yy h:mm a +datetime-format-short-with-zone = M/d/yy h:mm a zzz # Month of December December = December Default--interface-mode = Default diff --git a/src/realmModels/Observation.js b/src/realmModels/Observation.js index 660d971f5..a12de5c54 100644 --- a/src/realmModels/Observation.js +++ b/src/realmModels/Observation.js @@ -53,6 +53,7 @@ class Observation extends Realm.Object { place_guess: true, quality_grade: true, observation_sounds: ObservationSound.OBSERVATION_SOUNDS_FIELDS, + observed_time_zone: true, taxon: Taxon.TAXON_FIELDS, taxon_geoprivacy: true, time_observed_at: true, @@ -95,6 +96,7 @@ class Observation extends Realm.Object { latitude: true, longitude: true, observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS, + observed_time_zone: true, place_guess: true, quality_grade: true, obscured: true, @@ -488,6 +490,7 @@ class Observation extends Realm.Object { // date and/or time submitted to the server when a new obs is uploaded observed_on_string: "string?", observed_on: "string?", + observed_time_zone: "string?", obscured: "bool?", owners_identification_from_vision: "bool?", species_guess: "string?", diff --git a/src/realmModels/index.ts b/src/realmModels/index.ts index c9e7ad04b..f254fc1f5 100644 --- a/src/realmModels/index.ts +++ b/src/realmModels/index.ts @@ -33,7 +33,7 @@ export default { User, Vote ], - schemaVersion: 62, + schemaVersion: 63, path: `${RNFS.DocumentDirectoryPath}/db.realm`, // https://github.com/realm/realm-js/pull/6076 embedded constraints migrationOptions: { diff --git a/src/realmModels/types.d.ts b/src/realmModels/types.d.ts index d52ed7b48..eb7264c6b 100644 --- a/src/realmModels/types.d.ts +++ b/src/realmModels/types.d.ts @@ -23,15 +23,25 @@ export interface RealmSound extends RealmObject { file_url: string; } -export interface RealmObservationPhoto extends RealmObject { +export interface RealmObservationPhotoPojo extends RealmObject { originalPhotoUri?: string; photo: RealmPhoto; } -export interface RealmObservationSound extends RealmObject { +export interface RealmObservationPhoto extends RealmObservationPhotoPojo { + needsSync: ( ) => boolean; + wasSynced: ( ) => boolean; +} + +export interface RealmObservationSoundPojo extends RealmObject { sound: RealmSound; } +export interface RealmObservationSound extends RealmObject { + needsSync: ( ) => boolean; + wasSynced: ( ) => boolean; +} + export interface RealmTaxon extends RealmObject { id: number; defaultPhoto?: RealmPhoto, @@ -45,7 +55,7 @@ export interface RealmTaxon extends RealmObject { _synced_at?: Date; } -export interface RealmObservation extends RealmObject { +export interface RealmObservationPojo { _created_at?: Date; _synced_at?: Date; captive_flag: boolean | null; @@ -53,20 +63,35 @@ export interface RealmObservation extends RealmObject { geoprivacy: string | null; latitude: number | null; longitude: number | null; - missingBasics: ( ) => boolean; - needsSync: ( ) => boolean; - observationPhotos: Array; - observationSounds: Array; + obscured?: boolean; + observationPhotos: Array; + observationSounds: Array; + observed_on?: string; observed_on_string: string | null; + observed_time_zone?: string; owners_identification_from_vision: boolean | null; place_guess: string | null; positional_accuracy: number | null; species_guess: string | null; taxon_id: number | null; taxon?: RealmTaxon; + taxon_geoprivacy?: "open" | "private" | "obscured" | null; + time_observed_at?: string; + timeObservedAt?: string; uuid: string; } +export interface RealmObservation extends RealmObservationPojo { + missingBasics: ( ) => boolean; + needsSync: ( ) => boolean; + observationPhotos: Array; + observationSounds: Array; + unviewed: ( ) => boolean; + updateNeedsSync: ( ) => boolean; + viewed: ( ) => boolean; + wasSynced: ( ) => boolean; +} + export interface RealmUser extends RealmObject { iconUrl?: string; iconUrl?: string; diff --git a/src/sharedHelpers/dateAndTime.ts b/src/sharedHelpers/dateAndTime.ts index 20e1aa780..c14bbf796 100644 --- a/src/sharedHelpers/dateAndTime.ts +++ b/src/sharedHelpers/dateAndTime.ts @@ -77,6 +77,7 @@ import { zhCN, zhTW } from "date-fns/locale"; +import { formatInTimeZone } from "date-fns-tz"; import { i18n as i18next } from "i18next"; // Convert iNat locale to date-fns locale. Note that coverage is *not* @@ -284,12 +285,20 @@ function formatDifferenceForHumans( date: Date | string, i18n: i18next ) { return format( d, i18n.t( "date-format-month-day" ), formatOpts ); } -type FormatDateStringOptions = { +interface FormatDateStringOptions { + // Display the time as literally expressed in the dateString, i.e. don't + // assume it's in any time zone + literalTime?: boolean; + // Text to show if date is missing missing?: string | null; + // IANA time zone name + timeZone?: string; } +type DateString = string | null; + function formatDateString( - dateString: string, + dateString: DateString, fmt: string, i18n: i18next, options: FormatDateStringOptions = { } @@ -299,15 +308,29 @@ function formatDateString( ? i18n.t( "Missing-Date" ) : options.missing; } - return format( - parseISO( dateString ), + let timeZone = ( + // If we received a time zone, display the time in the requested zone + options.timeZone + // Otherwise use the system / local time zone + || Intl.DateTimeFormat( ).resolvedOptions( ).timeZone + ); + let isoDateString = dateString; + if ( options.literalTime ) { + isoDateString = dateString.replace( /[+-]\d\d:\d\d/, "" ); + isoDateString = isoDateString.replace( "Z", "" ); + // eslint-disable-next-line prefer-destructuring + timeZone = Intl.DateTimeFormat( ).resolvedOptions( ).timeZone; + } + return formatInTimeZone( + parseISO( isoDateString ), + timeZone, fmt, { locale: dateFnsLocale( i18n.language ) } ); } function formatMonthYearDate( - dateString: string, + dateString: DateString, i18n: i18next, options: FormatDateStringOptions = {} ) { @@ -315,7 +338,7 @@ function formatMonthYearDate( } function formatLongDate( - dateString: string, + dateString: DateString, i18n: i18next, options: FormatDateStringOptions = {} ) { @@ -323,27 +346,42 @@ function formatLongDate( } function formatLongDatetime( - dateString: string, + dateString: DateString, i18n: i18next, options: FormatDateStringOptions = {} ) { - return formatDateString( dateString, i18n.t( "datetime-format-long" ), i18n, options ); + const fmt = options.literalTime && !options.timeZone + ? i18n.t( "datetime-format-long" ) + : i18n.t( "datetime-format-long-with-zone" ); + return formatDateString( dateString, fmt, i18n, options ); } function formatApiDatetime( - dateString: string, + dateString: DateString, i18n: i18next, options: FormatDateStringOptions = {} ) { const hasTime = String( dateString ).includes( "T" ); if ( hasTime ) { - return formatDateString( dateString, i18n.t( "datetime-format-short" ), i18n, options ); + return formatDateString( + dateString, + options.literalTime && !options.timeZone + ? i18n.t( "datetime-format-short" ) + : i18n.t( "datetime-format-short-with-zone" ), + i18n, + options + ); } - return formatDateString( dateString, i18n.t( "date-format-short" ), i18n, options ); + return formatDateString( + dateString, + i18n.t( "date-format-short" ), + i18n, + options + ); } function formatProjectsApiDatetimeLong( - dateString: string, + dateString: DateString, i18n: i18next, options: FormatDateStringOptions = {} ) { @@ -354,7 +392,9 @@ function formatProjectsApiDatetimeLong( const hasSpaces = String( dateString ).includes( " " ); if ( hasSpaces ) { return formatDateString( - dateString.split( " " )[0], + dateString + ? dateString.split( " " )[0] + : dateString, i18n.t( "date-format-long" ), i18n, options diff --git a/src/sharedHelpers/parseExif.js b/src/sharedHelpers/parseExif.js index a9c8f6a2e..0059c4d46 100644 --- a/src/sharedHelpers/parseExif.js +++ b/src/sharedHelpers/parseExif.js @@ -1,6 +1,6 @@ // @flow -import { utcToZonedTime } from "date-fns-tz"; +import { toZonedTime } from "date-fns-tz"; import { readExif, writeLocation } from "react-native-exif-reader"; import { formatISONoTimezone } from "sharedHelpers/dateAndTime.ts"; @@ -18,7 +18,7 @@ export const parseExifDateToLocalTimezone = ( datetime: string ): ?Date => { // react-native-exif-reader formats the date based on GMT time, // so we create a date object here using GMT time, not the user's local timezone const isoDate = `${datetime}Z`; - const zonedDate = utcToZonedTime( isoDate, "GMT" ); + const zonedDate = toZonedTime( isoDate, "GMT" ); if ( !zonedDate || zonedDate.toString( ).match( /invalid/i ) ) { throw new UsePhotoExifDateFormatError( "Date was not formatted correctly" ); diff --git a/tests/unit/components/ObsEdit/DatePicker.test.js b/tests/unit/components/ObsEdit/DatePicker.test.js index d4a0cc0a9..887cedd7b 100644 --- a/tests/unit/components/ObsEdit/DatePicker.test.js +++ b/tests/unit/components/ObsEdit/DatePicker.test.js @@ -11,7 +11,8 @@ const mockLocalObservation = factory( "LocalObservation", { const mockRemoteObservation = factory( "RemoteObservation", { // jest timezone is set to UTC time time_observed_at: "2024-06-15T17:26:00-00:00", - observed_on_string: null + observed_on_string: null, + observed_time_zone: "UTC" } ); const mockLocalObservationNoDate = factory( "LocalObservation", { @@ -34,7 +35,7 @@ describe( "DatePicker", ( ) => { it( "displays date with no seconds from remote observation", ( ) => { renderComponent( ); - const date = screen.getByText( "06/15/2024, 5:26 PM" ); + const date = screen.getByText( "06/15/2024, 5:26 PM (UTC)" ); expect( date ).toBeVisible( ); } ); diff --git a/tests/unit/helpers/dateAndTime.test.js b/tests/unit/helpers/dateAndTime.test.js index 289937a08..3c249a766 100644 --- a/tests/unit/helpers/dateAndTime.test.js +++ b/tests/unit/helpers/dateAndTime.test.js @@ -46,32 +46,39 @@ describe( "formatApiDatetime", ( ) => { it( "should return a localized datetime when a datetime string is passed in", ( ) => { const date = "2022-11-02T18:43:00+00:00"; - expect( formatApiDatetime( date, i18next ) ).toEqual( "11/2/22 6:43 PM" ); + expect( formatApiDatetime( date, i18next ) ).toEqual( "11/2/22 6:43 PM UTC" ); } ); it( "should return a localized datetime for a remote observation created_at date", ( ) => { expect( - formatApiDatetime( remoteObservation.created_at, i18next ) - ).toEqual( "2/13/15 4:41 AM" ); + formatApiDatetime( remoteObservation.created_at, i18next, { inViewerTimeZone: true } ) + ).toEqual( "2/13/15 4:41 AM UTC" ); } ); it( "should return a localized datetime for a remote identification created_at date", ( ) => { expect( formatApiDatetime( remoteIdentification.created_at, i18next ) - ).toEqual( "2/13/15 5:12 AM" ); + ).toEqual( "2/13/15 5:12 AM UTC" ); } ); it( "should return a localized datetime for a remote comment created_at date", ( ) => { expect( formatApiDatetime( remoteComment.created_at, i18next ) - ).toEqual( "2/13/15 5:15 AM" ); + ).toEqual( "2/13/15 5:15 AM UTC" ); } ); it( "should return the date in the local time zone by default", () => { expect( process.env.TZ ).toEqual( "UTC" ); expect( formatApiDatetime( "2023-01-02T08:00:00+01:00", i18next ) - ).toEqual( "1/2/23 7:00 AM" ); + ).toEqual( "1/2/23 7:00 AM UTC" ); + } ); + + it( "should return the date in a requested time zone", () => { + expect( process.env.TZ ).toEqual( "UTC" ); + expect( + formatApiDatetime( "2023-01-02T08:00:00+01:00", i18next, { timeZone: "Asia/Tokyo" } ) + ).toEqual( "1/2/23 4:00 PM GMT+9" ); } ); it.todo(