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(