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",
|
"apisauce": "^3.0.1",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^3.0.0",
|
||||||
"date-fns-tz": "^2.0.1",
|
"date-fns-tz": "^3.0.0",
|
||||||
"i18next": "^23.10.1",
|
"i18next": "^23.10.1",
|
||||||
"i18next-fluent": "^2.0.0",
|
"i18next-fluent": "^2.0.0",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"i18next-resources-to-backend": "^1.2.0",
|
||||||
@@ -8752,26 +8752,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
"version": "2.30.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.21.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.11"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "github",
|
||||||
"url": "https://opencollective.com/date-fns"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/date-fns-tz": {
|
"node_modules/date-fns-tz": {
|
||||||
"version": "2.0.1",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||||
"integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==",
|
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"date-fns": "2.x"
|
"date-fns": "^3.0.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
|
|||||||
@@ -70,8 +70,8 @@
|
|||||||
"apisauce": "^3.0.1",
|
"apisauce": "^3.0.1",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^3.0.0",
|
||||||
"date-fns-tz": "^2.0.1",
|
"date-fns-tz": "^3.0.0",
|
||||||
"i18next": "^23.10.1",
|
"i18next": "^23.10.1",
|
||||||
"i18next-fluent": "^2.0.0",
|
"i18next-fluent": "^2.0.0",
|
||||||
"i18next-resources-to-backend": "^1.2.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>
|
<View>
|
||||||
{item.created_at && (
|
{item.created_at && (
|
||||||
<DateDisplay
|
<DateDisplay
|
||||||
|
asDifference
|
||||||
|
belongsToCurrentUser={belongsToCurrentUser}
|
||||||
dateString={item.updated_at || item.created_at}
|
dateString={item.updated_at || item.created_at}
|
||||||
geoprivacy={geoprivacy}
|
geoprivacy={geoprivacy}
|
||||||
taxonGeoprivacy={taxonGeoprivacy}
|
hideIcon
|
||||||
belongsToCurrentUser={belongsToCurrentUser}
|
|
||||||
maxFontSizeMultiplier={1}
|
maxFontSizeMultiplier={1}
|
||||||
|
taxonGeoprivacy={taxonGeoprivacy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -138,7 +138,12 @@ const DetailsTab = ( { currentUser, observation }: Props ): Node => {
|
|||||||
<DateDisplay
|
<DateDisplay
|
||||||
classNameMargin="mb-[12px]"
|
classNameMargin="mb-[12px]"
|
||||||
label={t( "Date-observed-header-short" )}
|
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
|
<DateDisplay
|
||||||
label={t( "Date-uploaded-header-short" )}
|
label={t( "Date-uploaded-header-short" )}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const ObsDetailsOverview = ( {
|
|||||||
taxonGeoprivacy={taxonGeoprivacy}
|
taxonGeoprivacy={taxonGeoprivacy}
|
||||||
belongsToCurrentUser={belongsToCurrentUser}
|
belongsToCurrentUser={belongsToCurrentUser}
|
||||||
maxFontSizeMultiplier={1}
|
maxFontSizeMultiplier={1}
|
||||||
|
timeZone={observation.observed_time_zone}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -132,11 +132,13 @@ const ActivityHeader = ( {
|
|||||||
<View>
|
<View>
|
||||||
{item.created_at && (
|
{item.created_at && (
|
||||||
<DateDisplay
|
<DateDisplay
|
||||||
|
asDifference
|
||||||
|
belongsToCurrentUser={belongsToCurrentUser}
|
||||||
dateString={item.updated_at || item.created_at}
|
dateString={item.updated_at || item.created_at}
|
||||||
geoprivacy={geoprivacy}
|
geoprivacy={geoprivacy}
|
||||||
taxonGeoprivacy={taxonGeoprivacy}
|
hideIcon
|
||||||
belongsToCurrentUser={belongsToCurrentUser}
|
|
||||||
maxFontSizeMultiplier={1}
|
maxFontSizeMultiplier={1}
|
||||||
|
taxonGeoprivacy={taxonGeoprivacy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import {
|
|||||||
import { View } from "components/styledComponents";
|
import { View } from "components/styledComponents";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Observation from "realmModels/Observation";
|
import type { RealmObservation } from "realmModels/types";
|
||||||
import { useCurrentUser } from "sharedHooks";
|
import { useCurrentUser } from "sharedHooks";
|
||||||
|
|
||||||
import ObscurationExplanation from "./ObscurationExplanation";
|
import ObscurationExplanation from "./ObscurationExplanation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
belongsToCurrentUser: boolean,
|
belongsToCurrentUser: boolean,
|
||||||
observation: Observation,
|
observation: RealmObservation,
|
||||||
handleLocationPickerPressed?: ( ) => void
|
handleLocationPickerPressed?: ( ) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ const LocationSection = ( {
|
|||||||
maxFontSizeMultiplier={1}
|
maxFontSizeMultiplier={1}
|
||||||
hideIcon
|
hideIcon
|
||||||
textComponent={List2}
|
textComponent={List2}
|
||||||
|
timeZone={observation.observed_time_zone}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const ObserverDetails = ( {
|
|||||||
taxonGeoprivacy={taxonGeoprivacy}
|
taxonGeoprivacy={taxonGeoprivacy}
|
||||||
belongsToCurrentUser={belongsToCurrentUser}
|
belongsToCurrentUser={belongsToCurrentUser}
|
||||||
maxFontSizeMultiplier={1}
|
maxFontSizeMultiplier={1}
|
||||||
|
timeZone={observation.observed_time_zone}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { Body2, DateTimePicker, INatIcon } from "components/SharedComponents";
|
import { Body2, DateTimePicker, INatIcon } from "components/SharedComponents";
|
||||||
import { Pressable, View } from "components/styledComponents";
|
import { Pressable, View } from "components/styledComponents";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import type { RealmObservationPojo } from "realmModels/types";
|
||||||
import {
|
import {
|
||||||
formatISONoSeconds,
|
formatISONoSeconds,
|
||||||
|
formatLongDate,
|
||||||
formatLongDatetime
|
formatLongDatetime
|
||||||
} from "sharedHelpers/dateAndTime.ts";
|
} from "sharedHelpers/dateAndTime.ts";
|
||||||
import useTranslation from "sharedHooks/useTranslation";
|
import useTranslation from "sharedHooks/useTranslation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentObservation: {
|
currentObservation: RealmObservationPojo;
|
||||||
observed_on_string: string;
|
|
||||||
time_observed_at: string;
|
|
||||||
};
|
|
||||||
updateObservationKeys: ( { observed_on_string }: { observed_on_string: string } ) => void;
|
updateObservationKeys: ( { observed_on_string }: { observed_on_string: string } ) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +22,9 @@ const DatePicker = ( { currentObservation, updateObservationKeys }: Props ) => {
|
|||||||
const closeModal = () => setShowModal( false );
|
const closeModal = () => setShowModal( false );
|
||||||
|
|
||||||
const observationDate = currentObservation?.observed_on_string
|
const observationDate = currentObservation?.observed_on_string
|
||||||
|| currentObservation?.time_observed_at;
|
|| currentObservation?.time_observed_at
|
||||||
|
|| currentObservation?.observed_on
|
||||||
|
|| null;
|
||||||
|
|
||||||
const handlePicked = ( value: Date ) => {
|
const handlePicked = ( value: Date ) => {
|
||||||
const dateString = formatISONoSeconds( value );
|
const dateString = formatISONoSeconds( value );
|
||||||
@@ -33,13 +34,21 @@ const DatePicker = ( { currentObservation, updateObservationKeys }: Props ) => {
|
|||||||
closeModal();
|
closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayDate = ( ) => formatLongDatetime(
|
const displayDate = ( ) => {
|
||||||
observationDate,
|
const opts = {
|
||||||
i18n,
|
literalTime: !currentObservation?.observed_time_zone,
|
||||||
{ missing: null }
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -90,12 +90,13 @@ const EvidenceSectionContainer = ( {
|
|||||||
] );
|
] );
|
||||||
|
|
||||||
const hasValidDate = useMemo( ( ) => {
|
const hasValidDate = useMemo( ( ) => {
|
||||||
const observationDate = parseISO(
|
const observationDateString = (
|
||||||
currentObservation?.observed_on_string || currentObservation?.time_observed_at
|
currentObservation?.observed_on_string
|
||||||
|
|| currentObservation?.time_observed_at
|
||||||
);
|
);
|
||||||
if ( observationDate
|
if ( observationDateString
|
||||||
&& !isFuture( observationDate )
|
&& !isFuture( parseISO( observationDateString ) )
|
||||||
&& differenceInCalendarYears( observationDate, new Date( ) ) <= 130
|
&& differenceInCalendarYears( parseISO( observationDateString ), new Date( ) ) <= 130
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ const ObsListItem = ( {
|
|||||||
classNameMargin="mt-1"
|
classNameMargin="mt-1"
|
||||||
geoprivacy={geoprivacy}
|
geoprivacy={geoprivacy}
|
||||||
taxonGeoprivacy={taxonGeoprivacy}
|
taxonGeoprivacy={taxonGeoprivacy}
|
||||||
|
timeZone={observation.observed_time_zone}
|
||||||
belongsToCurrentUser={belongsToCurrentUser}
|
belongsToCurrentUser={belongsToCurrentUser}
|
||||||
|
literalTime={!observation.observed_time_zone}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,31 +4,46 @@ import { Body4, INatIcon } from "components/SharedComponents";
|
|||||||
import { View } from "components/styledComponents";
|
import { View } from "components/styledComponents";
|
||||||
import type { Node } from "react";
|
import type { Node } from "react";
|
||||||
import React, { useMemo } 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";
|
import { useTranslation } from "sharedHooks";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label?: string,
|
// Display the date as a difference, or relative date, e.g. "1d" or "3w"
|
||||||
dateString: string,
|
asDifference?: boolean,
|
||||||
classNameMargin?: string,
|
|
||||||
geoprivacy?: string,
|
|
||||||
taxonGeoprivacy?: string,
|
|
||||||
belongsToCurrentUser?: boolean,
|
belongsToCurrentUser?: boolean,
|
||||||
maxFontSizeMultiplier?: number,
|
classNameMargin?: string,
|
||||||
|
dateString: string,
|
||||||
|
geoprivacy?: string,
|
||||||
hideIcon?: boolean,
|
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,
|
textComponent?: Function,
|
||||||
|
// Convert the time to the this time zone; otherwise display in the
|
||||||
|
// current / local time zone
|
||||||
|
timeZone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DateDisplay = ( {
|
const DateDisplay = ( {
|
||||||
|
asDifference,
|
||||||
belongsToCurrentUser,
|
belongsToCurrentUser,
|
||||||
classNameMargin,
|
classNameMargin,
|
||||||
dateString,
|
dateString,
|
||||||
geoprivacy,
|
geoprivacy,
|
||||||
label,
|
|
||||||
taxonGeoprivacy,
|
|
||||||
hideIcon,
|
hideIcon,
|
||||||
|
label,
|
||||||
|
literalTime,
|
||||||
|
maxFontSizeMultiplier = 2,
|
||||||
|
taxonGeoprivacy,
|
||||||
textComponent: TextComponentProp,
|
textComponent: TextComponentProp,
|
||||||
maxFontSizeMultiplier = 2
|
timeZone
|
||||||
}: Props ): Node => {
|
}: Props ): Node => {
|
||||||
const { i18n } = useTranslation( );
|
const { i18n } = useTranslation( );
|
||||||
|
|
||||||
@@ -37,40 +52,37 @@ const DateDisplay = ( {
|
|||||||
TextComponent = Body4;
|
TextComponent = Body4;
|
||||||
}
|
}
|
||||||
|
|
||||||
const obscuredDate = geoprivacy === "obscured"
|
const dateObscured = geoprivacy === "obscured"
|
||||||
|| taxonGeoprivacy === "obscured"
|
|| taxonGeoprivacy === "obscured"
|
||||||
|| geoprivacy === "private"
|
|| geoprivacy === "private"
|
||||||
|| taxonGeoprivacy === "private";
|
|| taxonGeoprivacy === "private";
|
||||||
|
|
||||||
const formatDate = useMemo( () => {
|
const formattedDate = useMemo( () => {
|
||||||
if ( !belongsToCurrentUser && obscuredDate ) {
|
if ( !belongsToCurrentUser && dateObscured ) {
|
||||||
return formatMonthYearDate( dateString, i18n );
|
return formatMonthYearDate( dateString, i18n );
|
||||||
}
|
}
|
||||||
return formatApiDatetime( dateString, i18n );
|
if ( asDifference ) {
|
||||||
|
return formatDifferenceForHumans( dateString, i18n );
|
||||||
|
}
|
||||||
|
return formatApiDatetime( dateString, i18n, { literalTime, timeZone } );
|
||||||
}, [
|
}, [
|
||||||
|
asDifference,
|
||||||
belongsToCurrentUser,
|
belongsToCurrentUser,
|
||||||
obscuredDate,
|
dateObscured,
|
||||||
dateString,
|
dateString,
|
||||||
i18n
|
i18n,
|
||||||
|
literalTime,
|
||||||
|
timeZone
|
||||||
] );
|
] );
|
||||||
|
|
||||||
const date = useMemo( ( ) => ( label
|
|
||||||
? `${label} `
|
|
||||||
: "" ) + formatDate, [formatDate, label] );
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={classNames( "flex flex-row items-center", classNameMargin )}>
|
<View className={classNames( "flex flex-row items-center", classNameMargin )}>
|
||||||
{!hideIcon && (
|
{!hideIcon && <INatIcon name="date" size={13} />}
|
||||||
<INatIcon
|
|
||||||
name="date"
|
|
||||||
size={13}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TextComponent
|
<TextComponent
|
||||||
className={!hideIcon && "ml-[5px]"}
|
className={!hideIcon && "ml-[5px]"}
|
||||||
maxFontSizeMultiplier={maxFontSizeMultiplier}
|
maxFontSizeMultiplier={maxFontSizeMultiplier}
|
||||||
>
|
>
|
||||||
{date}
|
{ `${label || ""} ${formattedDate}`.trim( ) }
|
||||||
</TextComponent>
|
</TextComponent>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -353,9 +353,11 @@ datetime-difference-weeks = { $count }w
|
|||||||
# Longer datetime, e.g. on ObsEdit
|
# Longer datetime, e.g. on ObsEdit
|
||||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||||
datetime-format-long = Pp
|
datetime-format-long = Pp
|
||||||
|
datetime-format-long-with-zone = Pp (zzz)
|
||||||
# Shorter datetime, e.g. on comments and IDs
|
# Shorter datetime, e.g. on comments and IDs
|
||||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
# 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 = M/d/yy h:mm a
|
||||||
|
datetime-format-short-with-zone = M/d/yy h:mm a zzz
|
||||||
# Month of December
|
# Month of December
|
||||||
December = December
|
December = December
|
||||||
Default--interface-mode = Default
|
Default--interface-mode = Default
|
||||||
|
|||||||
@@ -193,7 +193,9 @@
|
|||||||
"datetime-difference-minutes": "{ $count }m",
|
"datetime-difference-minutes": "{ $count }m",
|
||||||
"datetime-difference-weeks": "{ $count }w",
|
"datetime-difference-weeks": "{ $count }w",
|
||||||
"datetime-format-long": "Pp",
|
"datetime-format-long": "Pp",
|
||||||
|
"datetime-format-long-with-zone": "Pp (zzz)",
|
||||||
"datetime-format-short": "M/d/yy h:mm a",
|
"datetime-format-short": "M/d/yy h:mm a",
|
||||||
|
"datetime-format-short-with-zone": "M/d/yy h:mm a zzz",
|
||||||
"December": "December",
|
"December": "December",
|
||||||
"Default--interface-mode": "Default",
|
"Default--interface-mode": "Default",
|
||||||
"DELETE": "DELETE",
|
"DELETE": "DELETE",
|
||||||
|
|||||||
@@ -353,9 +353,11 @@ datetime-difference-weeks = { $count }w
|
|||||||
# Longer datetime, e.g. on ObsEdit
|
# Longer datetime, e.g. on ObsEdit
|
||||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||||
datetime-format-long = Pp
|
datetime-format-long = Pp
|
||||||
|
datetime-format-long-with-zone = Pp (zzz)
|
||||||
# Shorter datetime, e.g. on comments and IDs
|
# Shorter datetime, e.g. on comments and IDs
|
||||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
# 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 = M/d/yy h:mm a
|
||||||
|
datetime-format-short-with-zone = M/d/yy h:mm a zzz
|
||||||
# Month of December
|
# Month of December
|
||||||
December = December
|
December = December
|
||||||
Default--interface-mode = Default
|
Default--interface-mode = Default
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class Observation extends Realm.Object {
|
|||||||
place_guess: true,
|
place_guess: true,
|
||||||
quality_grade: true,
|
quality_grade: true,
|
||||||
observation_sounds: ObservationSound.OBSERVATION_SOUNDS_FIELDS,
|
observation_sounds: ObservationSound.OBSERVATION_SOUNDS_FIELDS,
|
||||||
|
observed_time_zone: true,
|
||||||
taxon: Taxon.TAXON_FIELDS,
|
taxon: Taxon.TAXON_FIELDS,
|
||||||
taxon_geoprivacy: true,
|
taxon_geoprivacy: true,
|
||||||
time_observed_at: true,
|
time_observed_at: true,
|
||||||
@@ -95,6 +96,7 @@ class Observation extends Realm.Object {
|
|||||||
latitude: true,
|
latitude: true,
|
||||||
longitude: true,
|
longitude: true,
|
||||||
observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS,
|
observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS,
|
||||||
|
observed_time_zone: true,
|
||||||
place_guess: true,
|
place_guess: true,
|
||||||
quality_grade: true,
|
quality_grade: true,
|
||||||
obscured: 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
|
// date and/or time submitted to the server when a new obs is uploaded
|
||||||
observed_on_string: "string?",
|
observed_on_string: "string?",
|
||||||
observed_on: "string?",
|
observed_on: "string?",
|
||||||
|
observed_time_zone: "string?",
|
||||||
obscured: "bool?",
|
obscured: "bool?",
|
||||||
owners_identification_from_vision: "bool?",
|
owners_identification_from_vision: "bool?",
|
||||||
species_guess: "string?",
|
species_guess: "string?",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default {
|
|||||||
User,
|
User,
|
||||||
Vote
|
Vote
|
||||||
],
|
],
|
||||||
schemaVersion: 62,
|
schemaVersion: 63,
|
||||||
path: `${RNFS.DocumentDirectoryPath}/db.realm`,
|
path: `${RNFS.DocumentDirectoryPath}/db.realm`,
|
||||||
// https://github.com/realm/realm-js/pull/6076 embedded constraints
|
// https://github.com/realm/realm-js/pull/6076 embedded constraints
|
||||||
migrationOptions: {
|
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;
|
file_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RealmObservationPhoto extends RealmObject {
|
export interface RealmObservationPhotoPojo extends RealmObject {
|
||||||
originalPhotoUri?: string;
|
originalPhotoUri?: string;
|
||||||
photo: RealmPhoto;
|
photo: RealmPhoto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RealmObservationSound extends RealmObject {
|
export interface RealmObservationPhoto extends RealmObservationPhotoPojo {
|
||||||
|
needsSync: ( ) => boolean;
|
||||||
|
wasSynced: ( ) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealmObservationSoundPojo extends RealmObject {
|
||||||
sound: RealmSound;
|
sound: RealmSound;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RealmObservationSound extends RealmObject {
|
||||||
|
needsSync: ( ) => boolean;
|
||||||
|
wasSynced: ( ) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RealmTaxon extends RealmObject {
|
export interface RealmTaxon extends RealmObject {
|
||||||
id: number;
|
id: number;
|
||||||
defaultPhoto?: RealmPhoto,
|
defaultPhoto?: RealmPhoto,
|
||||||
@@ -45,7 +55,7 @@ export interface RealmTaxon extends RealmObject {
|
|||||||
_synced_at?: Date;
|
_synced_at?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RealmObservation extends RealmObject {
|
export interface RealmObservationPojo {
|
||||||
_created_at?: Date;
|
_created_at?: Date;
|
||||||
_synced_at?: Date;
|
_synced_at?: Date;
|
||||||
captive_flag: boolean | null;
|
captive_flag: boolean | null;
|
||||||
@@ -53,20 +63,35 @@ export interface RealmObservation extends RealmObject {
|
|||||||
geoprivacy: string | null;
|
geoprivacy: string | null;
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
missingBasics: ( ) => boolean;
|
obscured?: boolean;
|
||||||
needsSync: ( ) => boolean;
|
observationPhotos: Array<RealmObservationPhotoPojo>;
|
||||||
observationPhotos: Array<RealmObservationPhoto>;
|
observationSounds: Array<RealmObservationSoundPojo>;
|
||||||
observationSounds: Array<RealmObservationSound>;
|
observed_on?: string;
|
||||||
observed_on_string: string | null;
|
observed_on_string: string | null;
|
||||||
|
observed_time_zone?: string;
|
||||||
owners_identification_from_vision: boolean | null;
|
owners_identification_from_vision: boolean | null;
|
||||||
place_guess: string | null;
|
place_guess: string | null;
|
||||||
positional_accuracy: number | null;
|
positional_accuracy: number | null;
|
||||||
species_guess: string | null;
|
species_guess: string | null;
|
||||||
taxon_id: number | null;
|
taxon_id: number | null;
|
||||||
taxon?: RealmTaxon;
|
taxon?: RealmTaxon;
|
||||||
|
taxon_geoprivacy?: "open" | "private" | "obscured" | null;
|
||||||
|
time_observed_at?: string;
|
||||||
|
timeObservedAt?: string;
|
||||||
uuid: 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 {
|
export interface RealmUser extends RealmObject {
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
zhCN,
|
zhCN,
|
||||||
zhTW
|
zhTW
|
||||||
} from "date-fns/locale";
|
} from "date-fns/locale";
|
||||||
|
import { formatInTimeZone } from "date-fns-tz";
|
||||||
import { i18n as i18next } from "i18next";
|
import { i18n as i18next } from "i18next";
|
||||||
|
|
||||||
// Convert iNat locale to date-fns locale. Note that coverage is *not*
|
// 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 );
|
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;
|
missing?: string | null;
|
||||||
|
// IANA time zone name
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DateString = string | null;
|
||||||
|
|
||||||
function formatDateString(
|
function formatDateString(
|
||||||
dateString: string,
|
dateString: DateString,
|
||||||
fmt: string,
|
fmt: string,
|
||||||
i18n: i18next,
|
i18n: i18next,
|
||||||
options: FormatDateStringOptions = { }
|
options: FormatDateStringOptions = { }
|
||||||
@@ -299,15 +308,29 @@ function formatDateString(
|
|||||||
? i18n.t( "Missing-Date" )
|
? i18n.t( "Missing-Date" )
|
||||||
: options.missing;
|
: options.missing;
|
||||||
}
|
}
|
||||||
return format(
|
let timeZone = (
|
||||||
parseISO( dateString ),
|
// 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,
|
fmt,
|
||||||
{ locale: dateFnsLocale( i18n.language ) }
|
{ locale: dateFnsLocale( i18n.language ) }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonthYearDate(
|
function formatMonthYearDate(
|
||||||
dateString: string,
|
dateString: DateString,
|
||||||
i18n: i18next,
|
i18n: i18next,
|
||||||
options: FormatDateStringOptions = {}
|
options: FormatDateStringOptions = {}
|
||||||
) {
|
) {
|
||||||
@@ -315,7 +338,7 @@ function formatMonthYearDate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatLongDate(
|
function formatLongDate(
|
||||||
dateString: string,
|
dateString: DateString,
|
||||||
i18n: i18next,
|
i18n: i18next,
|
||||||
options: FormatDateStringOptions = {}
|
options: FormatDateStringOptions = {}
|
||||||
) {
|
) {
|
||||||
@@ -323,27 +346,42 @@ function formatLongDate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatLongDatetime(
|
function formatLongDatetime(
|
||||||
dateString: string,
|
dateString: DateString,
|
||||||
i18n: i18next,
|
i18n: i18next,
|
||||||
options: FormatDateStringOptions = {}
|
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(
|
function formatApiDatetime(
|
||||||
dateString: string,
|
dateString: DateString,
|
||||||
i18n: i18next,
|
i18n: i18next,
|
||||||
options: FormatDateStringOptions = {}
|
options: FormatDateStringOptions = {}
|
||||||
) {
|
) {
|
||||||
const hasTime = String( dateString ).includes( "T" );
|
const hasTime = String( dateString ).includes( "T" );
|
||||||
if ( hasTime ) {
|
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(
|
function formatProjectsApiDatetimeLong(
|
||||||
dateString: string,
|
dateString: DateString,
|
||||||
i18n: i18next,
|
i18n: i18next,
|
||||||
options: FormatDateStringOptions = {}
|
options: FormatDateStringOptions = {}
|
||||||
) {
|
) {
|
||||||
@@ -354,7 +392,9 @@ function formatProjectsApiDatetimeLong(
|
|||||||
const hasSpaces = String( dateString ).includes( " " );
|
const hasSpaces = String( dateString ).includes( " " );
|
||||||
if ( hasSpaces ) {
|
if ( hasSpaces ) {
|
||||||
return formatDateString(
|
return formatDateString(
|
||||||
dateString.split( " " )[0],
|
dateString
|
||||||
|
? dateString.split( " " )[0]
|
||||||
|
: dateString,
|
||||||
i18n.t( "date-format-long" ),
|
i18n.t( "date-format-long" ),
|
||||||
i18n,
|
i18n,
|
||||||
options
|
options
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import { utcToZonedTime } from "date-fns-tz";
|
import { toZonedTime } from "date-fns-tz";
|
||||||
import { readExif, writeLocation } from "react-native-exif-reader";
|
import { readExif, writeLocation } from "react-native-exif-reader";
|
||||||
import { formatISONoTimezone } from "sharedHelpers/dateAndTime.ts";
|
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,
|
// 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
|
// so we create a date object here using GMT time, not the user's local timezone
|
||||||
const isoDate = `${datetime}Z`;
|
const isoDate = `${datetime}Z`;
|
||||||
const zonedDate = utcToZonedTime( isoDate, "GMT" );
|
const zonedDate = toZonedTime( isoDate, "GMT" );
|
||||||
|
|
||||||
if ( !zonedDate || zonedDate.toString( ).match( /invalid/i ) ) {
|
if ( !zonedDate || zonedDate.toString( ).match( /invalid/i ) ) {
|
||||||
throw new UsePhotoExifDateFormatError( "Date was not formatted correctly" );
|
throw new UsePhotoExifDateFormatError( "Date was not formatted correctly" );
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const mockLocalObservation = factory( "LocalObservation", {
|
|||||||
const mockRemoteObservation = factory( "RemoteObservation", {
|
const mockRemoteObservation = factory( "RemoteObservation", {
|
||||||
// jest timezone is set to UTC time
|
// jest timezone is set to UTC time
|
||||||
time_observed_at: "2024-06-15T17:26:00-00:00",
|
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", {
|
const mockLocalObservationNoDate = factory( "LocalObservation", {
|
||||||
@@ -34,7 +35,7 @@ describe( "DatePicker", ( ) => {
|
|||||||
it( "displays date with no seconds from remote observation", ( ) => {
|
it( "displays date with no seconds from remote observation", ( ) => {
|
||||||
renderComponent( <DatePicker currentObservation={mockRemoteObservation} /> );
|
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( );
|
expect( date ).toBeVisible( );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
|||||||
@@ -46,32 +46,39 @@ describe( "formatApiDatetime", ( ) => {
|
|||||||
|
|
||||||
it( "should return a localized datetime when a datetime string is passed in", ( ) => {
|
it( "should return a localized datetime when a datetime string is passed in", ( ) => {
|
||||||
const date = "2022-11-02T18:43:00+00:00";
|
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", ( ) => {
|
it( "should return a localized datetime for a remote observation created_at date", ( ) => {
|
||||||
expect(
|
expect(
|
||||||
formatApiDatetime( remoteObservation.created_at, i18next )
|
formatApiDatetime( remoteObservation.created_at, i18next, { inViewerTimeZone: true } )
|
||||||
).toEqual( "2/13/15 4:41 AM" );
|
).toEqual( "2/13/15 4:41 AM UTC" );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( "should return a localized datetime for a remote identification created_at date", ( ) => {
|
it( "should return a localized datetime for a remote identification created_at date", ( ) => {
|
||||||
expect(
|
expect(
|
||||||
formatApiDatetime( remoteIdentification.created_at, i18next )
|
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", ( ) => {
|
it( "should return a localized datetime for a remote comment created_at date", ( ) => {
|
||||||
expect(
|
expect(
|
||||||
formatApiDatetime( remoteComment.created_at, i18next )
|
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", () => {
|
it( "should return the date in the local time zone by default", () => {
|
||||||
expect( process.env.TZ ).toEqual( "UTC" );
|
expect( process.env.TZ ).toEqual( "UTC" );
|
||||||
expect(
|
expect(
|
||||||
formatApiDatetime( "2023-01-02T08:00:00+01:00", i18next )
|
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(
|
it.todo(
|
||||||
|
|||||||
Reference in New Issue
Block a user