feat: display time zones and times in time zones (#2636)

* fix: show observation datetime in the obs time zone

I.e. it doesn't offset the observation datetime into the viewer's time zone.

* test: adjust to literal times by default

* chore: update to date-fns 3.0

* wip: show time zone names with all times

* show time zone name whenever a time zone is passed to a formatting function
* store observation IANA time zone in Realm

Note that this required patching around a bug in Hermes in which it should be
returning a GMT offset for the short time zone but is instead just returning
GMT.

* fix: omit time zone for unuploaded obs

* feat: show relative time differences on ActivityItem headers

* fix: hide zone/offset on ObsEdit before upload when signed in

* fix: hide clock icon in activity item header in new default mode

Also

* stop using checkCamelAndSnakeCase when not necessary in DetailsTab.js
* make POJO types only refer to other POJO types
This commit is contained in:
Ken-ichi
2025-01-31 14:22:55 -08:00
committed by GitHub
parent 3fbb2c470a
commit 7e960d9010
23 changed files with 233 additions and 100 deletions

28
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -132,11 +132,13 @@ const ActivityHeader = ( {
<View>
{item.created_at && (
<DateDisplay
asDifference
belongsToCurrentUser={belongsToCurrentUser}
dateString={item.updated_at || item.created_at}
geoprivacy={geoprivacy}
taxonGeoprivacy={taxonGeoprivacy}
belongsToCurrentUser={belongsToCurrentUser}
hideIcon
maxFontSizeMultiplier={1}
taxonGeoprivacy={taxonGeoprivacy}
/>
)}
</View>

View File

@@ -138,7 +138,12 @@ const DetailsTab = ( { currentUser, observation }: Props ): Node => {
<DateDisplay
classNameMargin="mb-[12px]"
label={t( "Date-observed-header-short" )}
dateString={checkCamelAndSnakeCase( observation, "timeObservedAt" )}
dateString={
checkCamelAndSnakeCase( observation, "timeObservedAt" )
|| observation.observed_on_string
|| observation.observed_on
}
timeZone={observation.observed_time_zone}
/>
<DateDisplay
label={t( "Date-uploaded-header-short" )}

View File

@@ -75,6 +75,7 @@ const ObsDetailsOverview = ( {
taxonGeoprivacy={taxonGeoprivacy}
belongsToCurrentUser={belongsToCurrentUser}
maxFontSizeMultiplier={1}
timeZone={observation.observed_time_zone}
/>
)}
</View>

View File

@@ -132,11 +132,13 @@ const ActivityHeader = ( {
<View>
{item.created_at && (
<DateDisplay
asDifference
belongsToCurrentUser={belongsToCurrentUser}
dateString={item.updated_at || item.created_at}
geoprivacy={geoprivacy}
taxonGeoprivacy={taxonGeoprivacy}
belongsToCurrentUser={belongsToCurrentUser}
hideIcon
maxFontSizeMultiplier={1}
taxonGeoprivacy={taxonGeoprivacy}
/>
)}
</View>

View File

@@ -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}
/>
</View>
)}

View File

@@ -40,6 +40,7 @@ const ObserverDetails = ( {
taxonGeoprivacy={taxonGeoprivacy}
belongsToCurrentUser={belongsToCurrentUser}
maxFontSizeMultiplier={1}
timeZone={observation.observed_time_zone}
/>
)}
</View>

View File

@@ -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 (
<>

View File

@@ -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;
}

View File

@@ -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}
/>
</>
)}

View File

@@ -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 (
<View className={classNames( "flex flex-row items-center", classNameMargin )}>
{!hideIcon && (
<INatIcon
name="date"
size={13}
/>
)}
{!hideIcon && <INatIcon name="date" size={13} />}
<TextComponent
className={!hideIcon && "ml-[5px]"}
maxFontSizeMultiplier={maxFontSizeMultiplier}
>
{date}
{ `${label || ""} ${formattedDate}`.trim( ) }
</TextComponent>
</View>
);

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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?",

View File

@@ -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: {

View File

@@ -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<RealmObservationPhoto>;
observationSounds: Array<RealmObservationSound>;
obscured?: boolean;
observationPhotos: Array<RealmObservationPhotoPojo>;
observationSounds: Array<RealmObservationSoundPojo>;
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<RealmObservationPhoto>;
observationSounds: Array<RealmObservationSound>;
unviewed: ( ) => boolean;
updateNeedsSync: ( ) => boolean;
viewed: ( ) => boolean;
wasSynced: ( ) => boolean;
}
export interface RealmUser extends RealmObject {
iconUrl?: string;
iconUrl?: string;

View File

@@ -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

View File

@@ -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" );

View File

@@ -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( <DatePicker currentObservation={mockRemoteObservation} /> );
const date = screen.getByText( "06/15/2024, 5:26 PM" );
const date = screen.getByText( "06/15/2024, 5:26 PM (UTC)" );
expect( date ).toBeVisible( );
} );

View File

@@ -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(