diff --git a/web-app/package.json b/web-app/package.json index 433490d74..9eabbb0ea 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -14,15 +14,19 @@ "@mui/material": "^5.14.9", "@mui/x-tree-view": "^6.17.0", "@reduxjs/toolkit": "^1.9.6", + "@turf/center": "^6.5.0", + "@types/leaflet": "^1.9.12", "date-fns": "^2.30.0", "date-fns-tz": "^2.0.0", "firebase": "^10.4.0", "formik": "^2.4.5", + "leaflet": "^1.9.4", "openapi-fetch": "^0.9.3", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0", "react-ga4": "^2.1.0", "react-google-recaptcha": "^3.1.0", + "react-leaflet": "^4.2.1", "react-loading-overlay-ts": "^2.0.2", "react-redux": "^8.1.3", "react-router-dom": "^6.16.0", diff --git a/web-app/src/app/components/ContentBox.tsx b/web-app/src/app/components/ContentBox.tsx new file mode 100644 index 000000000..e47f1b165 --- /dev/null +++ b/web-app/src/app/components/ContentBox.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Box, Grid } from '@mui/material'; + +export interface ContentBoxProps { + title: string; + width: Record; + outlineColor: string; +} + +export const ContentBox = ( + props: React.PropsWithChildren, +): JSX.Element => { + return ( + + + + {props.title} + + {props.children} + + + ); +}; diff --git a/web-app/src/app/components/Map.tsx b/web-app/src/app/components/Map.tsx new file mode 100644 index 000000000..5f9f362a9 --- /dev/null +++ b/web-app/src/app/components/Map.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import 'leaflet/dist/leaflet.css'; +import { MapContainer, TileLayer, Polygon } from 'react-leaflet'; +import { type LatLngBoundsExpression, type LatLngExpression } from 'leaflet'; + +export interface MapProps { + polygon: LatLngExpression[]; +} + +export const Map = (props: React.PropsWithChildren): JSX.Element => { + return ( + + + + + ); +}; diff --git a/web-app/src/app/constants/Navigation.ts b/web-app/src/app/constants/Navigation.ts index e465fd592..c0c3a8970 100644 --- a/web-app/src/app/constants/Navigation.ts +++ b/web-app/src/app/constants/Navigation.ts @@ -16,6 +16,7 @@ export const MOBILITY_DATA_LINKS = { export const navigationItems: NavigationItem[] = [ { title: 'About', target: 'about', color: 'inherit', variant: 'text' }, + { title: 'Feeds', target: 'feeds', color: 'inherit', variant: 'text' }, { title: 'FAQ', target: 'faq', color: 'inherit', variant: 'text' }, { title: 'Add a Feed', diff --git a/web-app/src/app/router/Router.tsx b/web-app/src/app/router/Router.tsx index 9f06e58a6..64f876586 100644 --- a/web-app/src/app/router/Router.tsx +++ b/web-app/src/app/router/Router.tsx @@ -15,6 +15,7 @@ import Contribute from '../screens/Contribute'; import PostRegistration from '../screens/PostRegistration'; import TermsAndConditions from '../screens/TermsAndConditions'; import PrivacyPolicy from '../screens/PrivacyPolicy'; +import Feeds from '../screens/Feed'; export const AppRouter: React.FC = () => { return ( @@ -41,6 +42,7 @@ export const AppRouter: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web-app/src/app/screens/Feed/AssociatedGTFSFeeds.tsx b/web-app/src/app/screens/Feed/AssociatedGTFSFeeds.tsx new file mode 100644 index 000000000..3f30ecf11 --- /dev/null +++ b/web-app/src/app/screens/Feed/AssociatedGTFSFeeds.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { ContentBox } from '../../components/ContentBox'; +import { + TableBody, + TableCell, + TableContainer, + TableRow, + colors, +} from '@mui/material'; +import { OpenInNewOutlined } from '@mui/icons-material'; +import { type GTFSRTFeedType } from '../../services/feeds/utils'; + +export interface AssociatedGTFSFeedsProps { + feed: GTFSRTFeedType | undefined; +} + +export default function AssociatedGTFSFeeds({ + feed, +}: AssociatedGTFSFeedsProps): React.ReactElement { + return ( + + + + {feed?.data_type === 'gtfs_rt' && + feed?.feed_references?.map((feedRef) => { + return ( + + + + + {feedRef} + + + + + + ); + })} + + + + ); +} diff --git a/web-app/src/app/screens/Feed/DataQualitySummary.tsx b/web-app/src/app/screens/Feed/DataQualitySummary.tsx new file mode 100644 index 000000000..6a01dd66f --- /dev/null +++ b/web-app/src/app/screens/Feed/DataQualitySummary.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { ContentBox } from '../../components/ContentBox'; +import { + Chip, + TableCell, + TableContainer, + TableRow, + colors, +} from '@mui/material'; + +import { + ErrorOutlineOutlined, + OpenInNewOutlined, + ReportOutlined, + ReportProblemOutlined, +} from '@mui/icons-material'; +import { type components } from '../../services/feeds/types'; + +export interface DataQualitySummaryProps { + latestDataset: components['schemas']['GtfsDataset'] | undefined; +} + +export default function DataQualitySummary({ + latestDataset, +}: DataQualitySummaryProps): React.ReactElement { + return ( + + + + + } + label={`${ + latestDataset?.validation_report?.total_error ?? '0' + } Error`} + color='error' + variant='outlined' + /> + } + label={`${ + latestDataset?.validation_report?.total_warning ?? '0' + } Warning`} + color='warning' + variant='outlined' + /> + } + label={`${ + latestDataset?.validation_report?.total_info ?? '0' + } Info Notices`} + color='primary' + variant='outlined' + /> + + + + {latestDataset?.validation_report?.url_html !== undefined && ( + + + + Open Full Report + + + + + )} + {latestDataset?.validation_report?.url_json !== undefined && ( + + + + Open JSON Report + + + + + )} + + + + ); +} diff --git a/web-app/src/app/screens/Feed/FeaturesList.tsx b/web-app/src/app/screens/Feed/FeaturesList.tsx new file mode 100644 index 000000000..18801ced4 --- /dev/null +++ b/web-app/src/app/screens/Feed/FeaturesList.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { ContentBox } from '../../components/ContentBox'; +import { + TableBody, + TableCell, + TableContainer, + TableRow, + colors, +} from '@mui/material'; +import { type components } from '../../services/feeds/types'; + +export interface FeaturesListProps { + latestDataset: components['schemas']['GtfsDataset'] | undefined; +} + +export default function FeaturesList({ + latestDataset, +}: FeaturesListProps): React.ReactElement { + return ( + + + + {latestDataset?.validation_report?.features !== undefined && + latestDataset?.validation_report?.features?.length > 0 && ( + + + Feature + + + )} + {latestDataset?.validation_report?.features?.map((v) => ( + + {v} + + ))} + + + + ); +} diff --git a/web-app/src/app/screens/Feed/FeedSummary.tsx b/web-app/src/app/screens/Feed/FeedSummary.tsx new file mode 100644 index 000000000..8a6420ebe --- /dev/null +++ b/web-app/src/app/screens/Feed/FeedSummary.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { ContentBox } from '../../components/ContentBox'; +import { + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + colors, +} from '@mui/material'; +import { ContentCopy, ContentCopyOutlined } from '@mui/icons-material'; +import { + type GTFSFeedType, + type GTFSRTFeedType, +} from '../../services/feeds/utils'; + +export interface FeedSummaryProps { + feed: GTFSFeedType | GTFSRTFeedType | undefined; +} + +export default function FeedSummary({ + feed, +}: FeedSummaryProps): React.ReactElement { + return ( + + + + + + + Producer download URL: + + + + + + + + Data type: + + + + + + + + Location: + + + {feed?.locations !== undefined + ? Object.values(feed?.locations[0]) + .filter((v) => v !== null) + .reverse() + .join(', ') + : ''} + + + {feed?.data_type === 'gtfs' && ( + + + Last downloaded at: + + + {feed?.data_type === 'gtfs' && + feed.latest_dataset?.downloaded_at != null + ? new Date( + feed?.latest_dataset?.downloaded_at, + ).toUTCString() + : undefined} + + + )} + + + HTTP Auth Parameter: + + + {feed?.source_info?.api_key_parameter_name !== null + ? feed?.source_info?.api_key_parameter_name + : 'N/A'} + + + {feed?.data_type === 'gtfs' && ( + + + Feed contact email: + + + {feed?.feed_contact_email !== undefined && + feed?.feed_contact_email.length > 0 && ( + + )} + + + )} + +
+
+
+ ); +} diff --git a/web-app/src/app/screens/Feed/PreviousDatasets.tsx b/web-app/src/app/screens/Feed/PreviousDatasets.tsx new file mode 100644 index 000000000..1f9a20841 --- /dev/null +++ b/web-app/src/app/screens/Feed/PreviousDatasets.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { ContentBox } from '../../components/ContentBox'; +import { + Chip, + TableBody, + TableCell, + TableContainer, + TableRow, + colors, +} from '@mui/material'; +import { + DownloadOutlined, + ErrorOutlineOutlined, + OpenInNewOutlined, + ReportOutlined, + ReportProblemOutlined, +} from '@mui/icons-material'; +import { type paths } from '../../services/feeds/types'; + +export interface PreviousDatasetsProps { + datasets: + | paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json'] + | undefined; +} + +export default function PreviousDatasets({ + datasets, +}: PreviousDatasetsProps): React.ReactElement { + return ( + + + + {datasets?.map((dataset) => ( + + {dataset.downloaded_at != null && ( + + {new Date(dataset.downloaded_at).toDateString()} + + )} + + + Download Dataset + + + + +
+ } + label={`${ + dataset?.validation_report?.total_error ?? '0' + } Error`} + color='error' + variant='outlined' + /> + } + label={`${ + dataset?.validation_report?.total_warning ?? '0' + } Warning`} + color='warning' + variant='outlined' + /> + } + label={`${ + dataset?.validation_report?.total_info ?? '0' + } Info Notices`} + color='primary' + variant='outlined' + /> +
+
+ {dataset.validation_report != null && + dataset.validation_report !== undefined && ( + + + + Open Full Report + + + + + )} + {dataset.validation_report != null && + dataset.validation_report !== undefined && ( + + + Open JSON Report + + + )} +
+ ))} +
+
+
+ ); +} diff --git a/web-app/src/app/screens/Feed/index.tsx b/web-app/src/app/screens/Feed/index.tsx new file mode 100644 index 000000000..ac692876a --- /dev/null +++ b/web-app/src/app/screens/Feed/index.tsx @@ -0,0 +1,224 @@ +import * as React from 'react'; +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { + Box, + Container, + CssBaseline, + Typography, + Button, + Grid, + colors, +} from '@mui/material'; +import { + Download, + LaunchOutlined, + WarningAmberOutlined, +} from '@mui/icons-material'; +import '../../styles/SignUp.css'; +import '../../styles/FAQ.css'; +import { ContentBox } from '../../components/ContentBox'; +import { useAppDispatch } from '../../hooks'; +import { loadingFeed } from '../../store/feed-reducer'; +import { selectUserProfile } from '../../store/profile-selectors'; +import { + selectFeedData, + selectFeedLoadingStatus, + selectGTFSFeedData, + selectGTFSRTFeedData, +} from '../../store/feed-selectors'; +import { loadingDataset } from '../../store/dataset-reducer'; +import { + selectBoundingBoxFromLatestDataset, + selectDatasetsData, + selectLatestDatasetsData, +} from '../../store/dataset-selectors'; +import { Map } from '../../components/Map'; +import PreviousDatasets from './PreviousDatasets'; +import AssociatedGTFSFeeds from './AssociatedGTFSFeeds'; +import FeaturesList from './FeaturesList'; +import FeedSummary from './FeedSummary'; +import DataQualitySummary from './DataQualitySummary'; + +export default function Feed(): React.ReactElement { + const { feedId } = useParams(); + const user = useSelector(selectUserProfile); + const feedLoadingStatus = useSelector(selectFeedLoadingStatus); + const feedType = useSelector(selectFeedData)?.data_type; + const feed = + feedType === 'gtfs' + ? useSelector(selectGTFSFeedData) + : useSelector(selectGTFSRTFeedData); + const datasets = useSelector(selectDatasetsData); + const latestDataset = useSelector(selectLatestDatasetsData); + const boundingBox = useSelector(selectBoundingBoxFromLatestDataset); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (user?.accessToken !== undefined && feedId !== undefined) { + dispatch(loadingFeed({ feedId, accessToken: user?.accessToken })); + dispatch(loadingDataset({ feedId, accessToken: user?.accessToken })); + } + }, []); + + return ( + + + + + {feedLoadingStatus === 'error' && ( + <>There was an error loading the feed. + )} + {feedLoadingStatus === 'loading' && 'Loading...'} + {feedLoadingStatus === 'loaded' && ( + + + + + Feeds /{' '} + {feedType === 'gtfs' + ? 'GTFS Schedule' + : 'GTFS Realtime Schedule'}{' '} + / {feed?.id} + + + + + {feed?.provider?.substring(0, 100)} + + + {feed?.feed_name ?? ( + + {feed?.feed_name} + + )} + {feedType === 'gtfs_rt' && ( + + Vehicle Positions + + )} + + {feed?.redirects !== undefined && + feed?.redirects.length > 0 && ( + + + This feed has been replaced with a different producer URL. + + Go to the new feed here + + . + + )} + + + {feedType === 'gtfs' && ( + + )} + + + + + + + + + {boundingBox !== undefined && ( + + )} + + + {feed?.data_type === 'gtfs' && ( + + + + )} + {feed?.data_type === 'gtfs' && ( + + + + )} + {feed?.data_type === 'gtfs_rt' && ( + + + + )} + + + {feed?.data_type === 'gtfs' && ( + + + + )} + + )} + + + + ); +} diff --git a/web-app/src/app/services/feeds/index.ts b/web-app/src/app/services/feeds/index.ts index 0ed5ac275..2b30fe3ee 100644 --- a/web-app/src/app/services/feeds/index.ts +++ b/web-app/src/app/services/feeds/index.ts @@ -19,6 +19,8 @@ const throwOnError: Middleware = { }, }; +client.use(throwOnError); + const generateAuthMiddlewareWithToken = (accessToken: string): Middleware => { return { async onRequest(req) { @@ -29,8 +31,6 @@ const generateAuthMiddlewareWithToken = (accessToken: string): Middleware => { }; }; -client.use(throwOnError); - export const getFeeds = async (): Promise< | paths['/v1/feeds']['get']['responses'][200]['content']['application/json'] | undefined @@ -121,10 +121,13 @@ export const getGtfsFeed = async ( export const getGtfsRtFeed = async ( id: string, + accessToken: string, ): Promise< | paths['/v1/gtfs_rt_feeds/{id}']['get']['responses'][200]['content']['application/json'] | undefined > => { + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + client.use(authMiddleware); return await client .GET('/v1/gtfs_rt_feeds/{id}', { params: { path: { id } } }) .then((response) => { @@ -133,32 +136,47 @@ export const getGtfsRtFeed = async ( }) .catch(function (error) { throw error; + }) + .finally(() => { + client.eject(authMiddleware); }); }; export const getGtfsFeedDatasets = async ( id: string, + accessToken: string, + queryParams?: paths['/v1/gtfs_feeds/{id}/datasets']['get']['parameters']['query'], ): Promise< | paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json'] | undefined > => { + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + client.use(authMiddleware); return await client - .GET('/v1/gtfs_feeds/{id}/datasets', { params: { path: { id } } }) + .GET('/v1/gtfs_feeds/{id}/datasets', { + params: { query: queryParams, path: { id } }, + }) .then((response) => { const data = response.data; return data; }) .catch(function (error) { throw error; + }) + .finally(() => { + client.eject(authMiddleware); }); }; export const getDatasetGtfs = async ( id: string, + accessToken: string, ): Promise< | paths['/v1/datasets/gtfs/{id}']['get']['responses'][200]['content']['application/json'] | undefined > => { + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + client.use(authMiddleware); return await client .GET('/v1/datasets/gtfs/{id}', { params: { path: { id } } }) .then((response) => { @@ -167,6 +185,9 @@ export const getDatasetGtfs = async ( }) .catch(function (error) { throw error; + }) + .finally(() => { + client.eject(authMiddleware); }); }; diff --git a/web-app/src/app/services/feeds/types.ts b/web-app/src/app/services/feeds/types.ts index 056c59b59..386bd761a 100644 --- a/web-app/src/app/services/feeds/types.ts +++ b/web-app/src/app/services/feeds/types.ts @@ -26,7 +26,7 @@ export interface paths { get: operations['getGtfsRtFeeds']; }; '/v1/gtfs_feeds/{id}': { - /** @description Get the specified GTFS feed from the Mobility Database. */ + /** @description Get the specified GTFS feed from the Mobility Database. Once a week, we check if the latest dataset has been updated and, if so, we update it in our system accordingly. */ get: operations['getGtfsFeed']; parameters: { path: { @@ -44,7 +44,7 @@ export interface paths { }; }; '/v1/gtfs_feeds/{id}/datasets': { - /** @description Get a list of datasets related to a GTFS feed. */ + /** @description Get a list of datasets related to a GTFS feed. Once a week, we check if the latest dataset has been updated and, if so, we update it in our system accordingly. */ get: operations['getGtfsFeedDatasets']; parameters: { path: { @@ -172,6 +172,14 @@ export interface components { * @example ad3805c4941cd37881ff40c342e831b5f5224f3d8a9a2ec3ac197d3652c78e42 */ hash?: string; + validation_report?: { + /** @example 1 */ + total_error?: number; + /** @example 2 */ + total_warning?: number; + /** @example 3 */ + total_info?: number; + }; }; ExternalIds: Array; ExternalId: { @@ -309,7 +317,7 @@ export interface components { /** @example 8635fdac4fbff025b4eaca6972fcc9504bc1552d */ commit_hash?: string; }; - /** @description Validation report - Not yet supported. Can change in the future. */ + /** @description Validation report */ ValidationReport: { /** * Format: date-time @@ -317,7 +325,7 @@ export interface components { * @example "2023-07-10T22:06:00.000Z" */ validated_at?: string; - /** @description An array of components for this dataset. */ + /** @description An array of features for this dataset. */ features?: string[]; /** @example 4.2.0 */ validator_version?: string; @@ -339,12 +347,6 @@ export interface components { * @example https://storage.googleapis.com/mobilitydata-datasets-dev/mdb-10/mdb-10-202312181718/mdb-10-202312181718-report-4_2_0.html */ url_html?: string; - /** - * Format: url - * @description JSON validation system errors URL - * @example https://storage.googleapis.com/mobilitydata-datasets-dev/mdb-10/mdb-10-202312181718/mdb-10-202312181718-system-errors-4_2_0.json - */ - url_system_errors?: string; }; }; responses: never; @@ -514,7 +516,7 @@ export interface operations { }; }; }; - /** @description Get the specified GTFS feed from the Mobility Database. */ + /** @description Get the specified GTFS feed from the Mobility Database. Once a week, we check if the latest dataset has been updated and, if so, we update it in our system accordingly. */ getGtfsFeed: { parameters: { path: { @@ -546,7 +548,7 @@ export interface operations { }; }; }; - /** @description Get a list of datasets related to a GTFS feed. */ + /** @description Get a list of datasets related to a GTFS feed. Once a week, we check if the latest dataset has been updated and, if so, we update it in our system accordingly. */ getGtfsFeedDatasets: { parameters: { query?: { @@ -613,12 +615,12 @@ export interface operations { 200: { content: { 'application/json': { + /** @description The total number of matching entities found regardless the limit and offset parameters. */ + total?: number; results?: Array< | components['schemas']['GtfsFeed'] | components['schemas']['GtfsRTFeed'] >; - /** @description The total number of matching entities found regarless the limit and offset parameters. */ - total?: number; }; }; }; diff --git a/web-app/src/app/services/feeds/utils.ts b/web-app/src/app/services/feeds/utils.ts index 6befc8d2d..c9e05d9d5 100644 --- a/web-app/src/app/services/feeds/utils.ts +++ b/web-app/src/app/services/feeds/utils.ts @@ -35,3 +35,8 @@ export const isGtfsRtFeedType = ( ): data is paths['/v1/gtfs_rt_feeds/{id}']['get']['responses'][200]['content']['application/json'] => { return data !== undefined && data.data_type === 'gtfs_rt'; }; + +export type AllDatasetType = + | paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json'] + | paths['/v1/datasets/gtfs/{id}']['get']['responses'][200]['content']['application/json'] + | undefined; diff --git a/web-app/src/app/store/dataset-reducer.ts b/web-app/src/app/store/dataset-reducer.ts new file mode 100644 index 000000000..09bd09409 --- /dev/null +++ b/web-app/src/app/store/dataset-reducer.ts @@ -0,0 +1,76 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { type FeedErrors, FeedErrorSource, type FeedError } from '../types'; +import { type paths } from '../services/feeds/types'; + +interface DatasetState { + status: 'loading' | 'loaded'; + datasetId: string | undefined; + data: + | paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json'] + | undefined; + errors: FeedErrors; +} + +const initialState: DatasetState = { + status: 'loading', + datasetId: undefined, + data: undefined, + errors: { + [FeedErrorSource.DatabaseAPI]: null, + }, +}; + +export const datasetSlice = createSlice({ + name: 'dataset', + initialState, + reducers: { + updateDatasetId: ( + state, + action: PayloadAction<{ + datasetId: string; + }>, + ) => { + state.datasetId = action.payload?.datasetId; + }, + loadingDataset: ( + state, + action: PayloadAction<{ + feedId: string; + accessToken: string; + }>, + ) => { + state.status = 'loading'; + state.data = undefined; + state.errors = { + ...state.errors, + DatabaseAPI: initialState.errors.DatabaseAPI, + }; + }, + loadingDatasetSuccess: ( + state, + action: PayloadAction<{ + data: paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json']; + }>, + ) => { + state.status = 'loaded'; + state.data = action.payload?.data; + // state.datasetId = action.payload.data?.id; + state.errors = { + ...state.errors, + DatabaseAPI: initialState.errors.DatabaseAPI, + }; + }, + loadingDatasetFail: (state, action: PayloadAction) => { + state.errors.DatabaseAPI = action.payload; + }, + }, +}); + +export const { + updateDatasetId, + loadingDataset, + loadingDatasetFail, + loadingDatasetSuccess, +} = datasetSlice.actions; + +export default datasetSlice.reducer; diff --git a/web-app/src/app/store/dataset-selectors.ts b/web-app/src/app/store/dataset-selectors.ts new file mode 100644 index 000000000..f9acd6651 --- /dev/null +++ b/web-app/src/app/store/dataset-selectors.ts @@ -0,0 +1,49 @@ +import { type LatLngExpression } from 'leaflet'; +import { type components, type paths } from '../services/feeds/types'; +import { type RootState } from './store'; + +export const selectDatasetsData = ( + state: RootState, +): + | paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json'] + | undefined => { + return state.dataset.data; +}; + +export const selectLatestDatasetsData = ( + state: RootState, +): components['schemas']['GtfsDataset'] | undefined => { + return state.dataset.data !== undefined + ? state.dataset.data[state.dataset.data.length - 1] + : undefined; +}; + +export const selectBoundingBoxFromLatestDataset = ( + state: RootState, +): LatLngExpression[] | undefined => { + const latestDataset = selectLatestDatasetsData(state); + if (latestDataset === undefined) return undefined; + return latestDataset.bounding_box?.minimum_latitude !== undefined && + latestDataset.bounding_box?.maximum_latitude !== undefined && + latestDataset.bounding_box?.minimum_longitude !== undefined && + latestDataset.bounding_box?.maximum_longitude !== undefined + ? [ + [ + latestDataset.bounding_box?.minimum_latitude, + latestDataset.bounding_box?.minimum_longitude, + ], + [ + latestDataset.bounding_box?.minimum_latitude, + latestDataset.bounding_box?.maximum_longitude, + ], + [ + latestDataset.bounding_box?.maximum_latitude, + latestDataset.bounding_box?.maximum_longitude, + ], + [ + latestDataset.bounding_box?.maximum_latitude, + latestDataset.bounding_box?.minimum_longitude, + ], + ] + : undefined; +}; diff --git a/web-app/src/app/store/feed-reducer.ts b/web-app/src/app/store/feed-reducer.ts index 1345bdebf..1b9642a73 100644 --- a/web-app/src/app/store/feed-reducer.ts +++ b/web-app/src/app/store/feed-reducer.ts @@ -3,7 +3,7 @@ import { type FeedErrors, FeedErrorSource, type FeedError } from '../types'; import { type AllFeedType } from '../services/feeds/utils'; interface FeedState { - status: 'loading' | 'loaded'; + status: 'loading' | 'loaded' | 'error'; feedId: string | undefined; data: AllFeedType; errors: FeedErrors; @@ -59,6 +59,7 @@ export const feedSlice = createSlice({ }; }, loadingFeedFail: (state, action: PayloadAction) => { + state.status = 'error'; state.errors.DatabaseAPI = action.payload; }, }, diff --git a/web-app/src/app/store/feed-selectors.ts b/web-app/src/app/store/feed-selectors.ts index a0448696e..98aa36924 100644 --- a/web-app/src/app/store/feed-selectors.ts +++ b/web-app/src/app/store/feed-selectors.ts @@ -1,6 +1,8 @@ import { type GTFSFeedType, + type GTFSRTFeedType, isGtfsFeedType, + isGtfsRtFeedType, type BasicFeedType, } from '../services/feeds/utils'; import { type RootState } from './store'; @@ -8,11 +10,20 @@ import { type RootState } from './store'; export const selectFeedData = (state: RootState): BasicFeedType => { return state.feedProfile.data; }; + +export const selectFeedLoadingStatus = (state: RootState): string => { + return state.feedProfile.status; +}; export const selectGTFSFeedData = (state: RootState): GTFSFeedType => { return isGtfsFeedType(state.feedProfile.data) ? state.feedProfile.data : undefined; }; +export const selectGTFSRTFeedData = (state: RootState): GTFSRTFeedType => { + return isGtfsRtFeedType(state.feedProfile.data) + ? state.feedProfile.data + : undefined; +}; export const selectFeedId = (state: RootState): string => { return state.feedProfile.feedId ?? 'mdb-1'; diff --git a/web-app/src/app/store/reducers.ts b/web-app/src/app/store/reducers.ts index c7f63504d..6bd76f945 100644 --- a/web-app/src/app/store/reducers.ts +++ b/web-app/src/app/store/reducers.ts @@ -1,10 +1,12 @@ import { combineReducers } from 'redux'; import profileReducer from './profile-reducer'; import feedReducer from './feed-reducer'; +import datasetReducer from './dataset-reducer'; const rootReducer = combineReducers({ userProfile: profileReducer, feedProfile: feedReducer, + dataset: datasetReducer, }); export default rootReducer; diff --git a/web-app/src/app/store/saga/dataset-saga.ts b/web-app/src/app/store/saga/dataset-saga.ts new file mode 100644 index 000000000..87a6f082d --- /dev/null +++ b/web-app/src/app/store/saga/dataset-saga.ts @@ -0,0 +1,29 @@ +import { type StrictEffect, call, takeLatest, put } from 'redux-saga/effects'; +import { loadingFeedFail } from '../feed-reducer'; +import { getAppError } from '../../utils/error'; +import { DATASET_LOADING_FEED, type FeedError } from '../../types'; +import { type PayloadAction } from '@reduxjs/toolkit'; +import { getGtfsFeedDatasets } from '../../services/feeds'; +import { type paths } from '../../services/feeds/types'; +import { loadingDatasetSuccess } from '../dataset-reducer'; + +function* getDatasetSaga({ + payload: { feedId, accessToken }, +}: PayloadAction<{ feedId: string; accessToken: string }>): Generator< + StrictEffect, + void, + paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json'] +> { + try { + if (feedId !== undefined) { + const datasets = yield call(getGtfsFeedDatasets, feedId, accessToken, {}); + yield put(loadingDatasetSuccess({ data: datasets })); + } + } catch (error) { + yield put(loadingFeedFail(getAppError(error) as FeedError)); + } +} + +export function* watchDataset(): Generator { + yield takeLatest(DATASET_LOADING_FEED, getDatasetSaga); +} diff --git a/web-app/src/app/store/saga/feed-saga.ts b/web-app/src/app/store/saga/feed-saga.ts index 2c49ae336..172e4f588 100644 --- a/web-app/src/app/store/saga/feed-saga.ts +++ b/web-app/src/app/store/saga/feed-saga.ts @@ -3,7 +3,7 @@ import { loadingFeedFail, loadingFeedSuccess } from '../feed-reducer'; import { getAppError } from '../../utils/error'; import { FEED_PROFILE_LOADING_FEED, type FeedError } from '../../types'; import { type PayloadAction } from '@reduxjs/toolkit'; -import { getGtfsFeed } from '../../services/feeds'; +import { getFeed, getGtfsFeed, getGtfsRtFeed } from '../../services/feeds'; import { type AllFeedType } from '../../services/feeds/utils'; function* getFeedSaga({ @@ -15,7 +15,11 @@ function* getFeedSaga({ > { try { if (feedId !== undefined) { - const feed = yield call(getGtfsFeed, feedId, accessToken); + const basicFeed = yield call(getFeed, feedId, accessToken); + const feed = + basicFeed?.data_type === 'gtfs' + ? yield call(getGtfsFeed, feedId, accessToken) + : yield call(getGtfsRtFeed, feedId, accessToken); yield put(loadingFeedSuccess({ data: feed })); } } catch (error) { diff --git a/web-app/src/app/store/saga/root-saga.ts b/web-app/src/app/store/saga/root-saga.ts index cc32db8d1..8158d280c 100644 --- a/web-app/src/app/store/saga/root-saga.ts +++ b/web-app/src/app/store/saga/root-saga.ts @@ -2,9 +2,10 @@ import { all } from 'redux-saga/effects'; import { watchAuth } from './auth-saga'; import { watchProfile } from './profile-saga'; import { watchFeed } from './feed-saga'; +import { watchDataset } from './dataset-saga'; const rootSaga = function* (): Generator { - yield all([watchAuth(), watchProfile(), watchFeed()]); + yield all([watchAuth(), watchProfile(), watchFeed(), watchDataset()]); }; export default rootSaga; diff --git a/web-app/src/app/store/selectors.ts b/web-app/src/app/store/selectors.ts index 520afc1f1..1fab85232 100644 --- a/web-app/src/app/store/selectors.ts +++ b/web-app/src/app/store/selectors.ts @@ -2,6 +2,7 @@ import { type RootState } from './store'; export * from './profile-selectors'; export * from './feed-selectors'; +export * from './dataset-selectors'; export const selectLoadingApp = (state: RootState): boolean => { return ( diff --git a/web-app/src/app/types.ts b/web-app/src/app/types.ts index f3e14d6ad..0ad6d7dec 100644 --- a/web-app/src/app/types.ts +++ b/web-app/src/app/types.ts @@ -58,6 +58,11 @@ export const FEED_PROFILE_LOADING_FEED = `${FEED_PROFILE}/loadingFeed`; export const FEED_PROFILE_LOADING_FEED_SUCCESS = `${FEED_PROFILE}/loadingFeedSuccess`; export const FEED_PROFILE_LOADING_FEED_FAIL = `${FEED_PROFILE}/loadingFeedFail`; +export const DATASET_UPDATE_FEED_ID = `dataset/updateDatasetId`; +export const DATASET_LOADING_FEED = `dataset/loadingDataset`; +export const DATASET_LOADING_FEED_SUCCESS = `dataset/loadingDatasetSuccess`; +export const DATASET_LOADING_FEED_FAIL = `dataset/loadingDatasetFail`; + export enum ProfileErrorSource { SignUp = 'SignUp', Login = 'Login', diff --git a/web-app/src/app/utils/consts.ts b/web-app/src/app/utils/consts.ts new file mode 100644 index 000000000..b1a77cd7e --- /dev/null +++ b/web-app/src/app/utils/consts.ts @@ -0,0 +1,170 @@ +export class DATASET_FEATURES_FILES_MAPPING { + private constructor( + public readonly variable: string, + public readonly displayName: string, + ) {} + + public static getFeedFileByFeatureName(variable: string): string | undefined { + const mapping = Object.values(DATASET_FEATURES_FILES_MAPPING).find( + (mapping) => mapping.variable.toLowerCase() === variable.toLowerCase(), + ); + return mapping?.displayName; + } + + public static readonly faresV1 = new DATASET_FEATURES_FILES_MAPPING( + 'Fares V1', + 'fare_attributes.txt', + ); + + public static readonly textToSpeech = new DATASET_FEATURES_FILES_MAPPING( + 'Text-to-speech', + 'stops.txt - tts_stop_name', + ); + + public static readonly wheelchairAccessibilityTrips = + new DATASET_FEATURES_FILES_MAPPING( + 'Wheelchair accessibility', + 'trips.txt - wheelchair_accessible', + ); + + public static readonly wheelchairAccessibilityStops = + new DATASET_FEATURES_FILES_MAPPING( + 'Wheelchair accessibility', + 'stops.txt - wheelchair_boarding', + ); + + public static readonly routeColorsColor = new DATASET_FEATURES_FILES_MAPPING( + 'Route colors', + 'routes.txt - color', + ); + + public static readonly routeColorsRouteColor = + new DATASET_FEATURES_FILES_MAPPING( + 'Route colors', + 'routes.txt - route_color', + ); + + public static readonly bikesAllowed = new DATASET_FEATURES_FILES_MAPPING( + 'Bikes Allowed', + 'trips.txt - bikes_allowed', + ); + + public static readonly translations = new DATASET_FEATURES_FILES_MAPPING( + 'Translations', + 'translations.txt', + ); + + public static readonly headsignsTrips = new DATASET_FEATURES_FILES_MAPPING( + 'Headsigns', + 'trips.txt - trip_headsign', + ); + + public static readonly headsignsStopTimes = + new DATASET_FEATURES_FILES_MAPPING( + 'Headsigns', + 'stop_times.txt - stop_headsign', + ); + + public static readonly fareProducts = new DATASET_FEATURES_FILES_MAPPING( + 'Fare Products', + 'fare_products.txt', + ); + + public static readonly fareMedia = new DATASET_FEATURES_FILES_MAPPING( + 'Fare Media', + 'fare_media.txt', + ); + + public static readonly routeBasedFaresRoutes = + new DATASET_FEATURES_FILES_MAPPING( + 'Route-Based Fares', + 'routes.txt - network_id', + ); + + public static readonly routeBasedFaresNetworks = + new DATASET_FEATURES_FILES_MAPPING('Route-Based Fares', 'networks.txt'); + + public static readonly timeBasedFares = new DATASET_FEATURES_FILES_MAPPING( + 'Time-Based Fares', + 'timeframes.txt', + ); + + public static readonly zoneBasedFares = new DATASET_FEATURES_FILES_MAPPING( + 'Zone-Based Fares', + 'areas.txt', + ); + + public static readonly transferFares = new DATASET_FEATURES_FILES_MAPPING( + 'Transfer Fares', + 'fare_transfer_rules.txt', + ); + + public static readonly pathwaysBasic = new DATASET_FEATURES_FILES_MAPPING( + 'Pathways (basic)', + 'pathways.txt', + ); + + public static readonly pathwaysExtra = new DATASET_FEATURES_FILES_MAPPING( + 'Pathways (extra)', + 'pathways.txt - max_slope or max_width or length or stair_count', + ); + + public static readonly levels = new DATASET_FEATURES_FILES_MAPPING( + 'Levels', + 'levels.txt', + ); + + public static readonly inStationTraversalTime = + new DATASET_FEATURES_FILES_MAPPING( + 'In-station traversal time', + 'pathways.txt - traversal_time', + ); + + public static readonly pathwaysDirections = + new DATASET_FEATURES_FILES_MAPPING( + 'Pathways directions', + 'pathways.txt - signposted_as or reverse_signposted_as', + ); + + public static readonly locationTypes = new DATASET_FEATURES_FILES_MAPPING( + 'Location types', + 'stops.txt - location_type', + ); + + public static readonly feedInformation = new DATASET_FEATURES_FILES_MAPPING( + 'Feed Information', + 'feed_info.txt', + ); + + public static readonly attributions = new DATASET_FEATURES_FILES_MAPPING( + 'Attributions', + 'attributions.txt', + ); + + public static readonly continuousStopsRoutes = + new DATASET_FEATURES_FILES_MAPPING( + 'Continuous Stops', + 'routes.txt - continuous_drop_off or continuous_pickup', + ); + + public static readonly continuousStopsStopTimes = + new DATASET_FEATURES_FILES_MAPPING( + 'Continuous Stops', + 'stop_times.txt - continuous_drop_off or continuous_pickup', + ); + + public static readonly shapes = new DATASET_FEATURES_FILES_MAPPING( + 'Shapes', + 'shapes.txt', + ); + + public static readonly transfers = new DATASET_FEATURES_FILES_MAPPING( + 'Transfers', + 'transfers.txt', + ); + + public static readonly frequencies = new DATASET_FEATURES_FILES_MAPPING( + 'Frequencies', + 'frequencies.txt', + ); +} diff --git a/web-app/yarn.lock b/web-app/yarn.lock index e4ffbcb98..8c19c9b66 100644 --- a/web-app/yarn.lock +++ b/web-app/yarn.lock @@ -2728,6 +2728,11 @@ lodash.get "^4.4.2" render-and-add-props "^0.5.0" +"@react-leaflet/core@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@react-leaflet/core/-/core-2.1.0.tgz#383acd31259d7c9ae8fb1b02d5e18fe613c2a13d" + integrity sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg== + "@redux-saga/core@^1.2.3": version "1.2.3" resolved "https://registry.npmjs.org/@redux-saga/core/-/core-1.2.3.tgz" @@ -3048,6 +3053,34 @@ resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@turf/bbox@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5" + integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/center@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/center/-/center-6.5.0.tgz#3bcb6bffcb8ba147430cfea84aabaed5dbdd4f07" + integrity sha512-T8KtMTfSATWcAX088rEDKjyvQCBkUsLnK/Txb6/8WUXIeOZyHu42G7MkdkHRoHtwieLdduDdmPLFyTdG5/e7ZQ== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/helpers@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e" + integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw== + +"@turf/meta@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca" + integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA== + dependencies: + "@turf/helpers" "^6.5.0" + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz" @@ -3176,6 +3209,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/geojson@*": + version "7946.0.14" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.14.tgz#319b63ad6df705ee2a65a73ef042c8271e696613" + integrity sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg== + "@types/glob@*": version "8.1.0" resolved "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz" @@ -3266,6 +3304,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/leaflet@^1.9.12": + version "1.9.12" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.12.tgz#a6626a0b3fba36fd34723d6e95b22e8024781ad6" + integrity sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg== + dependencies: + "@types/geojson" "*" + "@types/linkify-it@*": version "3.0.5" resolved "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz" @@ -9682,6 +9727,11 @@ lazystream@^1.0.0: dependencies: readable-stream "^2.0.5" +leaflet@^1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" + integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== + leven@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" @@ -11970,6 +12020,13 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-leaflet@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780" + integrity sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q== + dependencies: + "@react-leaflet/core" "^2.1.0" + react-loading-overlay-ts@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/react-loading-overlay-ts/-/react-loading-overlay-ts-2.0.2.tgz"