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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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