diff --git a/lib/utils/date-utils.js b/lib/utils/date-utils.js index a153a0f90..3ee185720 100644 --- a/lib/utils/date-utils.js +++ b/lib/utils/date-utils.js @@ -1,164 +1,169 @@ // @flow import dateFormat from 'dateformat'; import invariant from 'invariant'; // Javascript uses 0-indexed months which is weird?? function getDate( yearInput: number, monthInput: number, dayOfMonth: number, ): Date { return new Date(yearInput, monthInput - 1, dayOfMonth); } function padMonthOrDay(n: number): string | number { return n < 10 ? '0' + n : n; } function daysInMonth(year: number, month: number) { switch (month) { case 2: return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28; case 4: case 6: case 9: case 11: return 30; default: return 31; } } function dateString( first: Date | number, month?: number, day?: number, ): string { if (arguments.length === 1) { return dateFormat(first, 'yyyy-mm-dd'); } else if (arguments.length === 3) { invariant(month && day, 'month/day should be set in call to dateString'); invariant(typeof first === 'number', 'first param should be a number'); return `${first}-${padMonthOrDay(month)}-${padMonthOrDay(day)}`; } invariant(false, 'incorrect number of params passed to dateString'); } function startDateForYearAndMonth(year: number, month: number): string { return dateString(year, month, 1); } function endDateForYearAndMonth(year: number, month: number): string { return dateString(year, month, daysInMonth(year, month)); } function fifteenDaysEarlier(timeZone?: ?string): string { const earlier = currentDateInTimeZone(timeZone); earlier.setDate(earlier.getDate() - 15); return dateString(earlier); } function fifteenDaysLater(timeZone?: ?string): string { const later = currentDateInTimeZone(timeZone); later.setDate(later.getDate() + 15); return dateString(later); } function prettyDate(dayString: string): string { return dateFormat(dateFromString(dayString), 'dddd, mmmm dS, yyyy'); } +function prettyDateWithoutDay(dayString: string): string { + return dateFormat(dateFromString(dayString), 'mmmm dS, yyyy'); +} + function dateFromString(dayString: string): Date { const matches = dayString.match(/^([0-9]+)-([0-1][0-9])-([0-3][0-9])$/); invariant(matches && matches.length === 4, `invalid dayString ${dayString}`); return getDate( parseInt(matches[1], 10), parseInt(matches[2], 10), parseInt(matches[3], 10), ); } const millisecondsInDay = 24 * 60 * 60 * 1000; const millisecondsInWeek = millisecondsInDay * 7; const millisecondsInYear = millisecondsInDay * 365; // Takes a millisecond timestamp and displays the time in the local timezone function shortAbsoluteDate(timestamp: number): string { const now = Date.now(); const msSince = now - timestamp; const date = new Date(timestamp); if (msSince < millisecondsInDay) { return dateFormat(date, 'h:MM TT'); } else if (msSince < millisecondsInWeek) { return dateFormat(date, 'ddd'); } else if (msSince < millisecondsInYear) { return dateFormat(date, 'mmm d'); } else { return dateFormat(date, 'mmm d yyyy'); } } // Same as above, but longer function longAbsoluteDate(timestamp: number): string { const now = Date.now(); const msSince = now - timestamp; const date = new Date(timestamp); if (msSince < millisecondsInDay) { return dateFormat(date, 'h:MM TT'); } else if (msSince < millisecondsInWeek) { return dateFormat(date, 'ddd h:MM TT'); } else if (msSince < millisecondsInYear) { return dateFormat(date, 'mmmm d, h:MM TT'); } else { return dateFormat(date, 'mmmm d yyyy, h:MM TT'); } } function thisMonthDates( timeZone?: ?string, ): { startDate: string, endDate: string } { const now = currentDateInTimeZone(timeZone); const year = now.getFullYear(); const month = now.getMonth() + 1; return { startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), }; } // The Date object doesn't support time zones, and is hardcoded to the server's // time zone. Thus, the best way to convert Date between time zones is to offset // the Date by the difference between the time zones function changeTimeZone(date: Date, timeZone: ?string): Date { if (!timeZone) { return date; } const localeString = date.toLocaleString('en-US', { timeZone }); const localeDate = new Date(localeString); const diff = localeDate.getTime() - date.getTime(); return new Date(date.getTime() + diff); } function currentDateInTimeZone(timeZone: ?string): Date { return changeTimeZone(new Date(), timeZone); } const threeDays = millisecondsInDay * 3; export { getDate, padMonthOrDay, dateString, startDateForYearAndMonth, endDateForYearAndMonth, fifteenDaysEarlier, fifteenDaysLater, prettyDate, + prettyDateWithoutDay, dateFromString, shortAbsoluteDate, longAbsoluteDate, thisMonthDates, currentDateInTimeZone, threeDays, }; diff --git a/web/modals/history/history-modal.react.js b/web/modals/history/history-modal.react.js index 528819301..a4789e536 100644 --- a/web/modals/history/history-modal.react.js +++ b/web/modals/history/history-modal.react.js @@ -1,284 +1,282 @@ // @flow import classNames from 'classnames'; -import dateFormat from 'dateformat'; import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _unionBy from 'lodash/fp/unionBy'; import * as React from 'react'; import { fetchEntriesActionTypes, fetchEntries, fetchRevisionsForEntryActionTypes, fetchRevisionsForEntry, } from 'lib/actions/entry-actions'; import { useModalContext } from 'lib/components/modal-provider.react'; import { nonExcludeDeletedCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { EntryInfo, CalendarQuery, FetchEntryInfosResult, } from 'lib/types/entry-types'; import { type CalendarFilter } from 'lib/types/filter-types'; import type { HistoryMode, HistoryRevisionInfo } from 'lib/types/history-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import { dateFromString } from 'lib/utils/date-utils'; +import { prettyDateWithoutDay } from 'lib/utils/date-utils'; import LoadingIndicator from '../../loading-indicator.react'; import { useSelector } from '../../redux/redux-utils'; import { allDaysToEntries } from '../../selectors/entry-selectors'; import Modal from '../modal.react'; import HistoryEntry from './history-entry.react'; import HistoryRevision from './history-revision.react'; import css from './history.css'; type BaseProps = { +mode: HistoryMode, +dayString: string, +currentEntryID?: ?string, }; type Props = { ...BaseProps, +entryInfos: ?(EntryInfo[]), +dayLoadingStatus: LoadingStatus, +entryLoadingStatus: LoadingStatus, +calendarFilters: $ReadOnlyArray, +dispatchActionPromise: DispatchActionPromise, +fetchEntries: ( calendarQuery: CalendarQuery, ) => Promise, +fetchRevisionsForEntry: ( entryID: string, ) => Promise<$ReadOnlyArray>, +onClose: () => void, }; type State = { +mode: HistoryMode, +animateModeChange: boolean, +currentEntryID: ?string, +revisions: $ReadOnlyArray, }; class HistoryModal extends React.PureComponent { static defaultProps = { currentEntryID: null }; constructor(props: Props) { super(props); this.state = { mode: props.mode, animateModeChange: false, currentEntryID: props.currentEntryID, revisions: [], }; } componentDidMount() { this.loadDay(); if (this.state.mode === 'entry') { invariant(this.state.currentEntryID, 'entry ID should be set'); this.loadEntry(this.state.currentEntryID); } } render() { let allHistoryButton = null; if (this.state.mode === 'entry') { allHistoryButton = ( < all entries ); } - const historyDate = dateFromString(this.props.dayString); - const prettyDate = dateFormat(historyDate, 'mmmm dS, yyyy'); + const prettyDate = prettyDateWithoutDay(this.props.dayString); const loadingStatus = this.state.mode === 'day' ? this.props.dayLoadingStatus : this.props.entryLoadingStatus; let entries; const entryInfos = this.props.entryInfos; if (entryInfos) { entries = _flow( _filter((entryInfo: EntryInfo) => entryInfo.id), _map((entryInfo: EntryInfo) => { const serverID = entryInfo.id; invariant(serverID, 'serverID should be set'); return ( ); }), )(entryInfos); } else { entries = []; } const revisionInfos = this.state.revisions.filter( revisionInfo => revisionInfo.entryID === this.state.currentEntryID, ); const revisions = []; for (let i = 0; i < revisionInfos.length; i++) { const revisionInfo = revisionInfos[i]; const nextRevisionInfo = revisionInfos[i + 1]; const isDeletionOrRestoration = nextRevisionInfo !== undefined && revisionInfo.deleted !== nextRevisionInfo.deleted; revisions.push( , ); } const animate = this.state.animateModeChange; const dayMode = this.state.mode === 'day'; const dayClasses = classNames({ [css.dayHistory]: true, [css.dayHistoryVisible]: dayMode && !animate, [css.dayHistoryInvisible]: !dayMode && !animate, [css.dayHistoryVisibleAnimate]: dayMode && animate, [css.dayHistoryInvisibleAnimate]: !dayMode && animate, }); const entryMode = this.state.mode === 'entry'; const entryClasses = classNames({ [css.entryHistory]: true, [css.entryHistoryVisible]: entryMode && !animate, [css.entryHistoryInvisible]: !entryMode && !animate, [css.entryHistoryVisibleAnimate]: entryMode && animate, [css.entryHistoryInvisibleAnimate]: !entryMode && animate, }); return (
{allHistoryButton} {prettyDate}
    {entries}
    {revisions}
); } loadDay() { this.props.dispatchActionPromise( fetchEntriesActionTypes, this.props.fetchEntries({ startDate: this.props.dayString, endDate: this.props.dayString, filters: this.props.calendarFilters, }), ); } loadEntry(entryID: string) { this.setState({ mode: 'entry', currentEntryID: entryID }); this.props.dispatchActionPromise( fetchRevisionsForEntryActionTypes, this.fetchRevisionsForEntryAction(entryID), ); } async fetchRevisionsForEntryAction(entryID: string) { const result = await this.props.fetchRevisionsForEntry(entryID); this.setState(prevState => { // This merge here will preserve time ordering correctly const revisions = _unionBy('id')(result)(prevState.revisions); return { ...prevState, revisions }; }); return { entryID, text: result[0].text, deleted: result[0].deleted, }; } onClickEntry = (entryID: string) => { this.setState({ animateModeChange: true }); this.loadEntry(entryID); }; onClickAllEntries = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ mode: 'day', animateModeChange: true, }); }; animateAndLoadEntry = (entryID: string) => { this.setState({ animateModeChange: true }); this.loadEntry(entryID); }; } const dayLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const entryLoadingStatusSelector = createLoadingStatusSelector( fetchRevisionsForEntryActionTypes, ); const ConnectedHistoryModal: React.ComponentType = React.memo( function ConnectedHistoryModal(props) { const entryInfos = useSelector( state => allDaysToEntries(state)[props.dayString], ); const dayLoadingStatus = useSelector(dayLoadingStatusSelector); const entryLoadingStatus = useSelector(entryLoadingStatusSelector); const calendarFilters = useSelector( nonExcludeDeletedCalendarFiltersSelector, ); const callFetchEntries = useServerCall(fetchEntries); const callFetchRevisionsForEntry = useServerCall(fetchRevisionsForEntry); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); return ( ); }, ); export default ConnectedHistoryModal;