mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
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:
28
package-lock.json
generated
28
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
21
patches/date-fns-tz+3.2.0.patch
Normal file
21
patches/date-fns-tz+3.2.0.patch
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" )}
|
||||
|
||||
@@ -75,6 +75,7 @@ const ObsDetailsOverview = ( {
|
||||
taxonGeoprivacy={taxonGeoprivacy}
|
||||
belongsToCurrentUser={belongsToCurrentUser}
|
||||
maxFontSizeMultiplier={1}
|
||||
timeZone={observation.observed_time_zone}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,7 @@ const ObserverDetails = ( {
|
||||
taxonGeoprivacy={taxonGeoprivacy}
|
||||
belongsToCurrentUser={belongsToCurrentUser}
|
||||
maxFontSizeMultiplier={1}
|
||||
timeZone={observation.observed_time_zone}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
39
src/realmModels/types.d.ts
vendored
39
src/realmModels/types.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" );
|
||||
|
||||
@@ -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( );
|
||||
} );
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user