diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml index 77e770925..3337f3acb 100644 --- a/.github/workflows/dependabot.yml +++ b/.github/workflows/dependabot.yml @@ -6,16 +6,16 @@ jobs: auto-approve-and-automerge: # sources: # https://github.com/hmarr/auto-approve-action + # https://github.com/marketplace/actions/enable-github-automerge # https://github.com/marketplace/actions/enable-pull-request-automerge#dependabot-example runs-on: ubuntu-latest permissions: pull-requests: write if: github.actor == 'dependabot[bot]' steps: - - uses: actions/checkout@v2 - name: Auto-approve PR uses: hmarr/auto-approve-action@v3 - name: Enable auto-merge - run: gh pr merge --merge --auto ${{ github.event.pull_request.number }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: alexwilson/enable-github-automerge-action@main + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file diff --git a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap index d79961eb0..4cf2c86dd 100644 --- a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap +++ b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap @@ -101,6 +101,8 @@ exports[`components > viewers > stop viewer should render countdown times after > viewers > stop viewer should render countdown times after } /> + + +
@@ -825,6 +832,91 @@ exports[`components > viewers > stop viewer should render countdown times after + + +
+ +
+ + + + + + + + + + + + + + components.StopViewer.noStopsFound + + +
+
+
+
+
viewers > stop viewer should render countdown times for st > viewers > stop viewer should render countdown times for st } /> + + +
@@ -3438,6 +3537,91 @@ exports[`components > viewers > stop viewer should render countdown times for st + + +
+ +
+ + + + + + + + + + + + + + components.StopViewer.noStopsFound + + +
+
+
+
+
viewers > stop viewer should render times after midnight w > viewers > stop viewer should render times after midnight w } /> + + +
@@ -5248,6 +5439,91 @@ exports[`components > viewers > stop viewer should render times after midnight w + + +
+ +
+ + + + + + + + + + + + + + components.StopViewer.noStopsFound + + +
+
+
+
+
viewers > stop viewer should render with OTP transit index > viewers > stop viewer should render with OTP transit index } /> + + +
@@ -8261,6 +8544,91 @@ exports[`components > viewers > stop viewer should render with OTP transit index + + +
+ +
+ + + + + + + + + + + + + + components.StopViewer.noStopsFound + + +
+
+
+
+
viewers > stop viewer should render with TriMet transit in > viewers > stop viewer should render with TriMet transit in } /> + + +
@@ -14912,6 +15287,91 @@ exports[`components > viewers > stop viewer should render with TriMet transit in + + +
+ +
+ + + + + + + + + + + + + + components.StopViewer.noStopsFound + + +
+
+
+
+
viewers > stop viewer should render with initial stop id a > viewers > stop viewer should render with initial stop id a } /> + + +
diff --git a/__tests__/util/ui.ts b/__tests__/util/ui.ts index 813f90d2e..314e604e4 100644 --- a/__tests__/util/ui.ts +++ b/__tests__/util/ui.ts @@ -1,4 +1,8 @@ -import { getItineraryView, ItineraryView } from '../../lib/util/ui' +import { + getItineraryView, + getMapToggleNewItineraryView, + ItineraryView +} from '../../lib/util/ui' describe('util > ui', () => { describe('getItineraryView', () => { @@ -17,18 +21,21 @@ describe('util > ui', () => { ) }) it('returns an itinerary list view if URL contains ui_activeItinerary=-1 regardless of ui_itineraryView', () => { - expect( - getItineraryView({ - ui_activeItinerary: -1, - ui_itineraryView: ItineraryView.FULL - }) - ).toBe(ItineraryView.LIST) - expect( - getItineraryView({ - ui_activeItinerary: -1, - ui_itineraryView: ItineraryView.LEG - }) - ).toBe(ItineraryView.LIST) + const expectedValues = { + [ItineraryView.FULL]: ItineraryView.LIST, + [ItineraryView.LEG]: ItineraryView.LIST, + [ItineraryView.LEG_HIDDEN]: ItineraryView.LIST, + [ItineraryView.LIST]: ItineraryView.LIST, + [ItineraryView.LIST_HIDDEN]: ItineraryView.LIST_HIDDEN + } + Object.entries(expectedValues).forEach(([k, v]) => { + expect( + getItineraryView({ + ui_activeItinerary: -1, + ui_itineraryView: k + }) + ).toBe(v) + }) }) it('returns the specified view mode when set in URL', () => { expect( @@ -39,4 +46,17 @@ describe('util > ui', () => { ).toBe(ItineraryView.LEG) }) }) + describe('getMapToggleNewItineraryView', () => { + it('should obtain the new itinerary view value', () => { + const expectedValues = { + [ItineraryView.LEG]: ItineraryView.LEG_HIDDEN, + [ItineraryView.LEG_HIDDEN]: ItineraryView.LEG, + [ItineraryView.LIST]: ItineraryView.LIST_HIDDEN, + [ItineraryView.LIST_HIDDEN]: ItineraryView.LIST + } + Object.entries(expectedValues).forEach(([k, v]) => { + expect(getMapToggleNewItineraryView(k)).toBe(v) + }) + }) + }) }) diff --git a/example-config.yml b/example-config.yml index cc270ada0..2bae9c943 100644 --- a/example-config.yml +++ b/example-config.yml @@ -310,6 +310,9 @@ itinerary: # Whether the plan first/previous/next/last buttons should be shown along with # plan trip itineraries. showPlanFirstLastButtons: false + # Filters out trips returned by OTP by default, unless specifically requested. + # e.g. filters out walk-only itineraries if user has not explicitly asked for them. + strictItineraryFiltering: false # Whether to render route names and colors in the blocks inside # the batch ui rows renderRouteNamesInBlocks: true diff --git a/i18n/es.yml b/i18n/es.yml index 63e8913cd..4df1c29df 100644 --- a/i18n/es.yml +++ b/i18n/es.yml @@ -63,6 +63,8 @@ actions: smsVerificationFailed: >- Su teléfono no ha podido ser verificado. Quizás el código que has introducido ha caducado. Solicita un nuevo código e inténtalo de nuevo. + mustBeLoggedInToSavePlace: Por favor, inicia la sesión para guardar las ubicaciones. + placeRemembered: La configuración de este lugar se ha guardado. common: coordinates: "{lat}; {lon}" dateExpressions: @@ -489,6 +491,7 @@ components: operatedBy: Servicio operado por {agencyName} selectADirection: Seleccione una dirección… stopsTo: Hacia + headsignTo: '{headsign} ({lastStop})' RouteViewer: agencyFilter: Filtro de agencia allAgencies: Todas las agencias diff --git a/i18n/tr.yml b/i18n/tr.yml new file mode 100644 index 000000000..d3363dd05 --- /dev/null +++ b/i18n/tr.yml @@ -0,0 +1,135 @@ +_id: en-US +actions: + fieldTrip: + saveItinerariesError: 'Seyahat planları kaydedilemedi: {err}' + setDateError: 'Tarih ayarlanırken hata oluştu:' + fetchFieldTripError: '' + setGroupSizeError: 'Grup boyutu ayarlanırken hata oluştu:' + maxTripRequestsExceeded: Geçerli sonuçlar olmadan yolculuk isteklerinin sayısı + aşıldı + setPaymentError: 'Ödeme bilgileri ayarlanırken hata oluştu:' + setRequestStatusError: 'İstek durumu ayarlanırken hata oluştu:' + editSubmitterNotesError: 'Gönderenin notları düzenlenirken hata oluştu:' + user: + emailVerificationResent: E-posta doğrulama mesajı yeniden gönderildi. + genericError: 'Bir hata ile karşılaşıldı: {err}' + smsVerificationFailed: Telefonunuz doğrulanamadı. Girdiğiniz kodun süresi dolmuş + olabilir. Lütfen yeni bir kod isteyin ve tekrar deneyin. + authTokenError: Yetkilendirme anahtarı alınırken hata oluştu. + mustBeLoggedInToSavePlace: Konumları kaydetmek için lütfen giriş yapın. + itineraryExistenceCheckFailed: Seçtiğiniz seyahatin mümkün olup olmadığı kontrol + edilirken hata oluştu. + smsInvalidCode: Girdiğiniz kod geçersiz. Lütfen tekrar deneyin. + accountDeleted: Kullanıcı hesabınız ({email}) silindi. + preferencesSaved: Tercihleriniz kaydedilmiştir. + placeRemembered: Bu yerin ayarları kaydedildi. + confirmDeletePlace: Bu yeri kaldırmak ister misiniz? + smsResendThrottled: Belirtilen telefon numarasına bir dakikadan kısa süre önce + bir doğrulama SMS'i gönderildi. Lütfen birkaç dakika sonra tekrar deneyin. + location: + userDeniedPermission: Kullanıcı izni reddetti + deniedAccessAlert: "Konumunuza erişim engellendi.\nMevcut konumunuzu kullanmak + için tarayıcınızdan konum izinlerini etkinleştirin ve sayfayı yeniden yükleyin. + \n" + unknownPositionError: Konum alınırken bilinmeyen hata + geolocationNotSupportedError: Coğrafi konum tarayıcınız tarafından desteklenmiyor + callTaker: + fetchCallsError: 'Çağrılar alınırken hata oluştu: {err}' + queryFetchError: 'Sorgular alınırken hata oluştu: {err}' + map: + currentLocation: (Mevcut Konum) +common: + daysOfWeekPlural: + thursday: Perşembeler + tuesday: Salılar + saturday: Cumartesileri + friday: Cumalar + monday: Pazartesileri + wednesday: Çarşambalar + sunday: Pazarları + modes: + car: Araba + micromobility_rent: E-Skutır + subway: Metro + micromobility: E-Skutır + rent: Kiralama seçenekleri + walk: Yürümek + funicular: Füniküler + car_park: Park Et Devam Et + flex: Esnek Rotalar + ferry: Feribot + bike: Bisiklet + tram: Tramvay + gondola: Gondol + drive: Sürücü + cable_car: Teleferik + rail: Demiryolu + bus: Otobüs + bicycle_rent: Bisiklet paylaşımı + notifications: + email: eposta + push: bildirimler + sms: SMS + daysOfWeekCompact: + wednesday: Çar + thursday: Per + sunday: Paz + tuesday: Sal + saturday: Cmt + friday: Cum + monday: Pzt + itineraryDescriptions: + fareUnknown: Ücret bilgisi yok + forms: + "yes": Evet + print: Yazdır + "no": Hayır + finish: Bitiş + submitting: Gönderiliyor… + startOver: Baştan Başla + close: Kapat + edit: Düzenle + delete: Sil + save: Kaydet + cancel: İptal + back: Geri + error: hata! + next: Sonraki + defaultValue: '{value} (varsayılan)' + daysOfWeek: + sunday: Pazar + wednesday: Çarşamba + saturday: Cumartesi + friday: Cuma + monday: Pazartesi + tuesday: Salı + thursday: Perşembe + dateExpressions: + tomorrow: Yarın + yesterday: Dün + today: Bugün + coordinates: '{lat}, {lon}' + linkOpensNewWindow: (Yeni pencere açar) + places: + home: ev + dining: yemek + work: iş + searchForms: + click: tıkla + time: + duration: + aFewSeconds: birkaç saniye +components: + AddPlaceButton: + addPlace: Yer ekle + tooManyPlaces: Maksimum ara yerlere ulaşıldı + needOriginDestination: Ara yerler eklemek için başlangıç/bitiş noktasını tanımlayın + AfterSignInScreen: + mainTitle: Yönlendiriliyor... + A11yPrefs: + accessibilityRoutingByDefault: Varsayılan olarak erişilebilir seyahatleri tercih + et + AdvancedOptions: + preferredRoutes: Tercih edilen rotaları seçin... + bannedRoutes: Yasaklanan rotaları seç… +_name: İngilizce diff --git a/lib/actions/apiV2.js b/lib/actions/apiV2.js index c4bf3ef0b..b6ba5140f 100644 --- a/lib/actions/apiV2.js +++ b/lib/actions/apiV2.js @@ -2,6 +2,7 @@ import { aggregateModes, populateSettingWithValue } from '@opentripplanner/trip-form' +import { createAction } from 'redux-actions' import { decodeQueryParams, DelimitedArrayParam } from 'use-query-params' import clone from 'clone' import coreUtils from '@opentripplanner/core-utils' @@ -46,7 +47,8 @@ import { RoutingQueryCallResult } from './api-constants' import { setItineraryView } from './ui' import { zoomToPlace } from './map' -const { generateCombinations, generateOtp2Query } = coreUtils.queryGen +const { generateCombinations, generateOtp2Query, SIMPLIFICATIONS } = + coreUtils.queryGen const { getTripOptionsFromQuery, getUrlParams } = coreUtils.query const { convertGraphQLResponseToLegacy } = coreUtils.itinerary const { randId } = coreUtils.storage @@ -851,6 +853,8 @@ export function routingQuery(searchId = null, updateSearchInReducer) { config?.modes?.initialState?.enabledModeButtons || {} + const strictModes = config?.itinerary?.strictItineraryFiltering + // Filter mode definitions based on active mode keys const activeModeButtons = config.modes?.modeButtons.filter((mb) => activeModeKeys.includes(mb.key) @@ -905,8 +909,6 @@ export function routingQuery(searchId = null, updateSearchInReducer) { }) ) - dispatch(setItineraryView(ItineraryView.LIST)) - combinations.forEach((combo, index) => { const query = generateOtp2Query(combo) dispatch( @@ -947,11 +949,31 @@ export function routingQuery(searchId = null, updateSearchInReducer) { routingError, { rewritePayload: (response, dispatch, getState) => { - const withCollapsedShortNames = - response.data?.plan?.itineraries?.map((itin) => ({ + const itineraries = response.data?.plan?.itineraries + + // Convert user-selected transit modes from mode selector into modes recognized by OTP. + const activeModeStrings = activeModes.map( + (am) => SIMPLIFICATIONS[am.mode] + ) + + let filteredItineraries = itineraries + // If "strictItineraryFiltering" is enabled, only return itineraries that contain at least one explicitly requested mode... + if (strictModes) { + filteredItineraries = itineraries.filter((itin) => + itin.legs.some((leg) => + activeModeStrings.includes(SIMPLIFICATIONS[leg.mode]) + ) + ) + // ... Otherwise return all itineraries. + } + + // Filter itineraries to collapse short names and hide unnecessary errors. + const withCollapsedShortNames = filteredItineraries.map( + (itin) => ({ ...itin, legs: itin.legs?.map(convertGraphQLResponseToLegacy) - })) + }) + ) /* It is possible for a NO_TRANSIT_CONNECTION error to be returned even if trips were returned, since it is on a mode-by-mode basis. @@ -1003,6 +1025,31 @@ export function routingQuery(searchId = null, updateSearchInReducer) { } } +const requestingServiceTimeRange = createAction('SERVICE_TIME_RANGE_REQUEST') +const receivedServiceTimeRange = createAction('SERVICE_TIME_RANGE_RESPONSE') +const receivedServiceTimeRangeError = createAction('SERVICE_TIME_RANGE_ERROR') + +/** Queries for service time range. */ +const retrieveServiceTimeRangeIfNeeded = () => + function (dispatch, getState) { + if (getState().otp.serviceTimeRange) return + dispatch(requestingServiceTimeRange) + return dispatch( + createGraphQLQueryAction( + `{ + serviceTimeRange { + start + end + } + }`, + {}, + receivedServiceTimeRange, + receivedServiceTimeRangeError, + {} + ) + ) + } + export default { fetchStopInfo, findPatternsForRoute, @@ -1010,6 +1057,7 @@ export default { findRoutes, findTrip, getVehiclePositionsForRoute, + retrieveServiceTimeRangeIfNeeded, routingQuery, vehicleRentalQuery } diff --git a/lib/actions/ui.js b/lib/actions/ui.js index 3b254fa61..21856fd9b 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -9,12 +9,14 @@ import { getMatchingLocaleString, loadLocaleData } from '../util/i18n' -import { getModesForActiveAgencyFilter, getUiUrlParams } from '../util/state' import { + getItineraryView, + getMapToggleNewItineraryView, getPathFromParts, isDefinedAndNotEqual, ItineraryView } from '../util/ui' +import { getModesForActiveAgencyFilter, getUiUrlParams } from '../util/state' import { clearActiveSearch, @@ -39,7 +41,7 @@ export const setHoveredStop = createAction('SET_HOVERED_STOP') const viewTrip = createAction('SET_VIEWED_TRIP') const viewRoute = createAction('SET_VIEWED_ROUTE') export const toggleAutoRefresh = createAction('TOGGLE_AUTO_REFRESH') -const setPreviousItineraryView = createAction('SET_PREVIOUS_ITINERARY_VIEW') +const settingItineraryView = createAction('SET_ITINERARY_VIEW') export const setPopupContent = createAction('SET_POPUP_CONTENT') // This code-less action calls the reducer code @@ -314,21 +316,15 @@ export function handleBackButtonPress(e) { export function setItineraryView(value) { return function (dispatch, getState) { const urlParams = coreUtils.query.getUrlParams() - const prevItineraryView = urlParams.ui_itineraryView || ItineraryView.LIST // If the itinerary value is changed, - // set the desired ui query param + // set the desired ui query param (even if LIST, so it replaces the current value) // and store the current view as previousItineraryView. - if (value !== urlParams.ui_itineraryView) { - if (value !== ItineraryView.LIST) { - urlParams.ui_itineraryView = value - } else if (urlParams.ui_itineraryView) { - // Remove the ui_itineraryView param if it is set to LIST (default). - delete urlParams.ui_itineraryView - } + if (value !== getItineraryView(urlParams)) { + urlParams.ui_itineraryView = value dispatch(setUrlSearch(urlParams)) - dispatch(setPreviousItineraryView(prevItineraryView)) + dispatch(settingItineraryView(value)) } } } @@ -340,16 +336,10 @@ export function setItineraryView(value) { export function toggleBatchResultsMap() { return function (dispatch, getState) { const urlParams = coreUtils.query.getUrlParams() - const itineraryView = urlParams.ui_itineraryView || ItineraryView.LIST + const itineraryView = getItineraryView(urlParams) - if (itineraryView === ItineraryView.LEG) { - dispatch(setItineraryView(ItineraryView.LEG_HIDDEN)) - } else if (itineraryView === ItineraryView.LIST) { - dispatch(setItineraryView(ItineraryView.LIST_HIDDEN)) - } else { - const { previousItineraryView } = getState().otp.ui - dispatch(setItineraryView(previousItineraryView)) - } + const newView = getMapToggleNewItineraryView(itineraryView) + dispatch(setItineraryView(newView)) } } diff --git a/lib/components/form/date-time-button.tsx b/lib/components/form/date-time-button.tsx index 3d6065c3e..26d554322 100644 --- a/lib/components/form/date-time-button.tsx +++ b/lib/components/form/date-time-button.tsx @@ -25,8 +25,10 @@ const ButtonWrapper = styled.span` position: relative; & > button { + background-color: var(--main-base-color, rgba(0, 0, 0, 0.5)); border-radius: 5px; border: none; + color: var(--main-color, white); cursor: pointer; font-size: 12px; height: ${buttonPixels}px; diff --git a/lib/components/form/styled.ts b/lib/components/form/styled.ts index 28938a89c..1067cfafa 100644 --- a/lib/components/form/styled.ts +++ b/lib/components/form/styled.ts @@ -24,9 +24,10 @@ const commonButtonCss = css` user-select: none; &.active { - background-color: rgb(173, 216, 230); + background-color: var(--main-base-color, rgb(173, 216, 230)); border: 2px solid rgb(0, 0, 0); box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + color: var(--main-color, white); font-weight: 600; } ` diff --git a/lib/components/mobile/batch-results-screen.js b/lib/components/mobile/batch-results-screen.js index 2e88d01e9..69119f1c3 100644 --- a/lib/components/mobile/batch-results-screen.js +++ b/lib/components/mobile/batch-results-screen.js @@ -143,7 +143,7 @@ const mapStateToProps = (state) => { activeLeg: activeSearch ? activeSearch.activeLeg : null, errors: getResponsesWithErrors(state), itineraries: getActiveItineraries(state), - itineraryView: getItineraryView(urlParams) + itineraryView: getItineraryView(urlParams) || state.otp.ui.itineraryView } } diff --git a/lib/components/mobile/location-search.js b/lib/components/mobile/location-search.js index 2b7ca0390..b58b22536 100644 --- a/lib/components/mobile/location-search.js +++ b/lib/components/mobile/location-search.js @@ -1,7 +1,6 @@ import { connect } from 'react-redux' import { injectIntl } from 'react-intl' import { - MenuItemA, MenuItemLi, MenuItemList } from '@opentripplanner/location-field/lib/styled' @@ -20,7 +19,7 @@ const MobileLocationField = styled(LocationField)` ${MenuItemList} { width: 100%; } - ${MenuItemA}, ${MenuItemLi} { + ${MenuItemLi} { overflow: hidden; padding-left: 5px; padding-right: 5px; diff --git a/lib/components/util/service-time-range-retriever.ts b/lib/components/util/service-time-range-retriever.ts new file mode 100644 index 000000000..6a0625fb7 --- /dev/null +++ b/lib/components/util/service-time-range-retriever.ts @@ -0,0 +1,32 @@ +import { connect } from 'react-redux' +import { useEffect } from 'react' + +import apiActionsV2 from '../../actions/apiV2' + +interface Props { + retrieveServiceTimeRangeIfNeeded: () => void +} + +/** + * Invisible component that retrieves the date range available + * for OTP planning and schedule retrieval. + */ +const ServiceTimeRangeRetriever = ({ + retrieveServiceTimeRangeIfNeeded +}: Props): null => { + // If not already done, retrieve the OTP available date range on mount. + useEffect(() => { + retrieveServiceTimeRangeIfNeeded() + }, [retrieveServiceTimeRangeIfNeeded]) + + // Component renders nothing + return null +} + +// Connect to redux +const mapDispatchToProps = { + retrieveServiceTimeRangeIfNeeded: + apiActionsV2.retrieveServiceTimeRangeIfNeeded +} + +export default connect(null, mapDispatchToProps)(ServiceTimeRangeRetriever) diff --git a/lib/components/viewers/stop-viewer.js b/lib/components/viewers/stop-viewer.js index f907f0d88..a580422a2 100644 --- a/lib/components/viewers/stop-viewer.js +++ b/lib/components/viewers/stop-viewer.js @@ -4,8 +4,10 @@ import { ArrowLeft } from '@styled-icons/fa-solid/ArrowLeft' import { Calendar } from '@styled-icons/fa-solid/Calendar' import { Clock } from '@styled-icons/fa-regular/Clock' import { connect } from 'react-redux' -import { format } from 'date-fns-tz' +import { ExclamationCircle } from '@styled-icons/fa-solid/ExclamationCircle' +import { format, parse } from 'date-fns' import { FormattedMessage, injectIntl } from 'react-intl' +import { format as formatTz, utcToZonedTime } from 'date-fns-tz' import { InfoCircle } from '@styled-icons/fa-solid/InfoCircle' import { Search } from '@styled-icons/fa-solid/Search' import { Star as StarRegular } from '@styled-icons/fa-regular/Star' @@ -24,10 +26,11 @@ import * as userActions from '../../actions/user' import { getPersistenceMode } from '../../util/user' import { getShowUserSettings, getStopViewerConfig } from '../../util/state' import { Icon, IconWithText, StyledIconWrapper } from '../util/styledIcon' -import { navigateBack } from '../../util/ui' +import { isBlank, navigateBack } from '../../util/ui' import { stopIsFlex } from '../../util/viewer' import OperatorLogo from '../util/operator-logo' import PageTitle from '../util/page-title' +import ServiceTimeRangeRetriever from '../util/service-time-range-retriever' import Strong from '../util/strong-text' import withMap from '../map/with-map' @@ -36,9 +39,13 @@ import StopScheduleTable from './stop-schedule-table' const { getCurrentDate, getUserTimezone } = coreUtils.time +/** The native date format used with elements */ +const inputDateFormat = 'yyyy-MM-dd' + function getDefaultState(timeZone) { return { // Compare dates/times in the stop viewer based on the agency's timezone. + // TODO: mock this date for percy tests. date: getCurrentDate(timeZone), isShowingSchedule: false } @@ -179,9 +186,18 @@ class StopViewer extends Component { } } + _isDateWithinRange = (date) => { + const { calendarMax, calendarMin } = this.props + return !isBlank(date) && date >= calendarMin && date <= calendarMax + } + handleDateChange = (evt) => { + // Check for non-empty date, and that date is within range before making request. + // (Users can enter a date outside of the range using the Up/Down arrow keys in Firefox and Safari.) const date = evt.target.value - this._findStopTimesForDate(date) + if (this._isDateWithinRange(date)) { + this._findStopTimesForDate(date) + } this.setState({ date }) } @@ -272,11 +288,11 @@ class StopViewer extends Component { /** * Plan trip from/to here buttons, plus the schedule/next arrivals toggle. - * TODO: Can this use SetFromToButtons? */ _renderControls = () => { - const { homeTimezone, intl, stopData } = this.props - const { isShowingSchedule } = this.state + const { calendarMax, calendarMin, homeTimezone, intl, stopData } = + this.props + const { date, isShowingSchedule } = this.state const inHomeTimezone = homeTimezone && homeTimezone === getUserTimezone() // Rewrite stop ID to not include Agency prefix, if present @@ -291,12 +307,22 @@ class StopViewer extends Component { const isFlex = stopIsFlex(stopData) let timezoneWarning - if (!inHomeTimezone) { - const timezoneCode = format(Date.now(), 'z', { - // To avoid ambiguities for now, use the English-US timezone abbreviations ("EST", "PDT", etc.) - locale: dateFnsUSLocale, - timeZone: homeTimezone - }) + if (!inHomeTimezone && this._isDateWithinRange(date)) { + // In schedule view, the time zone code should be that of the entered date, + // or the current day in the live view. + // This is to account for daylight time changes, especially when the live and + // schedule views are in different daylight saving periods. + const timezoneCode = formatTz( + isShowingSchedule && date + ? parse(date, inputDateFormat, new Date()) + : new Date(), // TODO: mock for percy tests, + 'z', + { + // To avoid ambiguities for now, use the English-US timezone abbreviations ("EST", "PDT", etc.) + locale: dateFnsUSLocale, + timeZone: homeTimezone + } + ) // Display a banner about the departure timezone if user's timezone is not the configured 'homeTimezone' // (e.g. cases where a user in New York looks at a schedule in Los Angeles). @@ -312,6 +338,16 @@ class StopViewer extends Component { ) } + if (!this._isDateWithinRange(date)) { + timezoneWarning = ( + + + + + + ) + } + return (
- ) + if (this._isDateWithinRange(date)) { + contents = ( + + ) + } } else { contents = ( <> @@ -453,6 +493,7 @@ class StopViewer extends Component { return (
+ {/* Header Block */} {this._renderHeader(agencyCount)} @@ -475,7 +516,7 @@ class StopViewer extends Component { const mapStateToProps = (state) => { const showUserSettings = getShowUserSettings(state) const stopViewerConfig = getStopViewerConfig(state) - const { config, transitIndex, ui } = state.otp + const { config, serviceTimeRange = {}, transitIndex, ui } = state.otp const { homeTimezone, language, persistence, stopViewer, transitOperators } = config const { autoRefreshStopTimes = true, favoriteStops } = state.user.localUser @@ -484,8 +525,33 @@ const mapStateToProps = (state) => { const nearbyStops = Array.from(new Set(stopData?.nearbyStops))?.map( (stopId) => stopLookup[stopId] ) + const now = new Date() + const thisYear = now.getFullYear() + const { end = 0, start = 0 } = serviceTimeRange + // If start is not provided, default to the first day of the current calendar year in the user's timezone. + // (No timezone conversion is needed in this case.) + // If start is provided in OTP, convert that date in the agency's home time zone. + const calendarMin = format( + start + ? utcToZonedTime(start * 1000, homeTimezone) + : new Date(thisYear, 0, 1), + inputDateFormat + ) + // If end is not provided, default to the last day of the next calendar year in the user's timezone. + // (No timezone conversion is needed in this case.) + // If end date is provided and falls at midnight agency time, + // use the previous second to get the last service day available. + const calendarMax = format( + end + ? utcToZonedTime((end - 1) * 1000, homeTimezone) + : new Date(thisYear + 1, 11, 31), + inputDateFormat + ) + return { autoRefreshStopTimes, + calendarMax, + calendarMin, enableFavoriteStops: getPersistenceMode(persistence).isLocalStorage, favoriteStops, homeTimezone, diff --git a/lib/reducers/create-otp-reducer.js b/lib/reducers/create-otp-reducer.js index 0888474e9..472598ab7 100644 --- a/lib/reducers/create-otp-reducer.js +++ b/lib/reducers/create-otp-reducer.js @@ -1053,9 +1053,9 @@ function createOtpReducer(config) { }) case 'UPDATE_ITINERARY_FILTER': return update(state, { filter: { $set: action.payload } }) - case 'SET_PREVIOUS_ITINERARY_VIEW': + case 'SET_ITINERARY_VIEW': return update(state, { - ui: { previousItineraryView: { $set: action.payload } } + ui: { itineraryView: { $set: action.payload } } }) case 'UPDATE_LOCALE': return update(state, { @@ -1088,6 +1088,18 @@ function createOtpReducer(config) { } } }) + case 'SERVICE_TIME_RANGE_REQUEST': + return update(state, { + serviceTimeRange: { $set: { pending: true } } + }) + case 'SERVICE_TIME_RANGE_RESPONSE': + return update(state, { + serviceTimeRange: { $set: action.payload.data.serviceTimeRange } + }) + case 'SERVICE_TIME_RANGE_ERROR': + return update(state, { + serviceTimeRange: { $set: { error: true } } + }) default: return state } diff --git a/lib/util/ui.ts b/lib/util/ui.ts index 54dba6d99..ed992e860 100644 --- a/lib/util/ui.ts +++ b/lib/util/ui.ts @@ -113,9 +113,29 @@ export function getItineraryView({ ((ui_activeItinerary === null || ui_activeItinerary === undefined || `${ui_activeItinerary}` === '-1') && - ItineraryView.LIST) || + (ui_itineraryView === ItineraryView.LIST_HIDDEN + ? ItineraryView.LIST_HIDDEN + : ItineraryView.LIST)) || ui_itineraryView || (isDefinedAndNotEqual(ui_activeItinerary, -1) && ItineraryView.FULL) || ItineraryView.LIST ) } + +/** + * Gets the new itinerary view to display based on current view. + */ +export function getMapToggleNewItineraryView( + currentView: ItineraryView +): ItineraryView { + switch (currentView) { + case ItineraryView.LEG: + return ItineraryView.LEG_HIDDEN + case ItineraryView.LIST: + return ItineraryView.LIST_HIDDEN + case ItineraryView.LEG_HIDDEN: + return ItineraryView.LEG + default: + return ItineraryView.LIST + } +} diff --git a/package.json b/package.json index a57707877..e25487124 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@opentripplanner/transit-vehicle-overlay": "^4.0.6", "@opentripplanner/transitive-overlay": "^3.0.16", "@opentripplanner/trip-details": "^5.0.4", - "@opentripplanner/trip-form": "^3.3.3", + "@opentripplanner/trip-form": "^3.3.4", "@opentripplanner/trip-viewer-overlay": "^2.0.7", "@opentripplanner/vehicle-rental-overlay": "^2.1.3", "@styled-icons/fa-regular": "^10.34.0", diff --git a/percy/mock.har b/percy/mock.har index 168b2f6c5..5762498e1 100644 --- a/percy/mock.har +++ b/percy/mock.har @@ -327,6 +327,42 @@ "_blocked_queueing": 8.100999999442138 } }, + { + "request": { + "bodySize": 118, + "method": "POST", + "url": "http://localhost:9999/otp2/routers/default/index/graphql", + "httpVersion": "HTTP/2", + "queryString": [], + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"{\\n serviceTimeRange {\\n start\\n end\\n }\\n }\",\"variables\":{}}" + } + }, + "response": { + "status": 200, + "statusText": "", + "httpVersion": "HTTP/2", + "content": { + "mimeType": "application/json", + "size": 67, + "text": "{\"data\":{\"serviceTimeRange\":{\"start\":1661745600,\"end\":1735707600}}}" + }, + "headersSize": 502, + "bodySize": 569 + }, + "cache": {}, + "timings": { + "blocked": -1, + "dns": 0, + "connect": 0, + "ssl": 0, + "send": 0, + "wait": 204, + "receive": 0 + }, + "time": 204 + }, { "request": { "method": "GET", diff --git a/yarn.lock b/yarn.lock index 2f581aacf..61aeaa722 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2561,10 +2561,10 @@ flat "^5.0.2" react-animate-height "^3.0.4" -"@opentripplanner/trip-form@^3.3.3": - version "3.3.3" - resolved "https://registry.yarnpkg.com/@opentripplanner/trip-form/-/trip-form-3.3.3.tgz#a7975eb219c7e23876e603132145365eedbb2638" - integrity sha512-frS1pdaOicVaGMenf25H12/Ne9KATHJ71ojLqvpSj1qA2MDHYXko07iymhgZ5kJw6wjdkRcIPQxc+rES9OZ3Sw== +"@opentripplanner/trip-form@^3.3.4": + version "3.3.4" + resolved "https://registry.yarnpkg.com/@opentripplanner/trip-form/-/trip-form-3.3.4.tgz#12847736515aa11e1c69c8db627a54c1ad5e7e89" + integrity sha512-adEjAJ+2ygkc6vptiD7tHI3O+1tEIvoDsM3+/DFIgvtKxkSKuRzzTNXn67CI2pmEev75aiiTyT97PiWbDKRTJw== dependencies: "@floating-ui/react" "^0.19.2" "@opentripplanner/core-utils" "^11.0.2"