diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 4ee86d0..9d03cb3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,10 +5,11 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', + 'prettier', ], ignorePatterns: ['dist', '.eslintrc.cjs'], parser: '@typescript-eslint/parser', - plugins: ['react', 'react-hooks', '@typescript-eslint', 'import', 'react-refresh'], + plugins: ['react', 'react-hooks', '@typescript-eslint', 'import', 'react-refresh', 'prettier'], rules: { 'import/named': 'warn', 'import/no-self-import': 'error', @@ -28,6 +29,7 @@ module.exports = { ignoreTypeImports: false, }, ], + 'prettier/prettier': 'error', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, }; diff --git a/.gitignore b/.gitignore index a547bf3..dd0f772 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.pyc # Editor directories and files .vscode/* diff --git a/package-lock.json b/package-lock.json index e395c09..42aaf09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,12 +39,14 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-prettier": "^5.1.2", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.33", - "prettier": "3.2.1", + "prettier": "3.2.5", "tailwindcss": "^3.4.1", "typescript": "^5.2.2", "vite": "^5.0.8" @@ -2996,6 +2998,18 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@react-native-community/cli": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-12.3.0.tgz", @@ -6872,6 +6886,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -7001,6 +7027,36 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react": { "version": "7.33.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", @@ -7383,6 +7439,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -11201,9 +11263,9 @@ } }, "node_modules/prettier": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.1.tgz", - "integrity": "sha512-qSUWshj1IobVbKc226Gw2pync27t0Kf0EdufZa9j7uBSJay1CC+B3K5lAAZoqgX3ASiKuWsk6OmzKRetXNObWg==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -11215,6 +11277,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", @@ -12834,6 +12908,22 @@ "react": ">=17.0" } }, + "node_modules/synckit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", + "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/tailwindcss": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", diff --git a/package.json b/package.json index 29048ad..9fb20cb 100644 --- a/package.json +++ b/package.json @@ -48,12 +48,14 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-prettier": "^5.1.2", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.33", - "prettier": "3.2.1", + "prettier": "3.2.5", "tailwindcss": "^3.4.1", "typescript": "^5.2.2", "vite": "^5.0.8" diff --git a/src/api/traveltimes.ts b/src/api/traveltimes.ts index 070cca7..8ea82e6 100644 --- a/src/api/traveltimes.ts +++ b/src/api/traveltimes.ts @@ -52,10 +52,7 @@ export const fetchAggregateData = async ( return name === QueryNameKeys.traveltimes ? responseJson : { by_date: responseJson }; }; -export const useTripExplorerQueries = ( - parameters: AggregateAPIOptions, - enabled = true -) => { +export const useTripExplorerQueries = (parameters: AggregateAPIOptions, enabled = true) => { const queryTypes = [QueryNameKeys.traveltimes, QueryNameKeys.headways, QueryNameKeys.dwells]; const dependencies = aggregateQueryDependencies; // Create objects with keys of query names which contains keys and parameters. @@ -78,8 +75,7 @@ export const useTripExplorerQueries = ( queries: queryTypes.map((name) => { return { queryKey: [name, queries[name].params], - queryFn: () => - fetchAggregateData(name, queries[name].params), + queryFn: () => fetchAggregateData(name, queries[name].params), enabled, staleTime: ONE_MINUTE, }; diff --git a/src/api/types.ts b/src/api/types.ts index c8effd6..7aa5e1e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -17,7 +17,6 @@ export const QUERIES: { [key in RouteType]: QueryNameOptions[] } = { cr: [QueryNameKeys.traveltimes, QueryNameKeys.headways], }; - export enum AggregateAPIParams { stop = 'stop', fromStop = 'from_stop', diff --git a/src/components/DirectionIndicator.tsx b/src/components/DirectionIndicator.tsx new file mode 100644 index 0000000..4f6f487 --- /dev/null +++ b/src/components/DirectionIndicator.tsx @@ -0,0 +1,15 @@ +import { ArrowDownCircleIcon } from '@heroicons/react/24/solid'; +import { ArrowUpCircleIcon } from '@heroicons/react/24/solid'; +import React from 'react'; + +interface DirectionIndicatorProps { + direction?: 'up' | 'down'; +} + +export const DirectionIndicator: React.FC = ({ direction }) => { + if (direction === 'up') { + return ; + } else if (direction === 'down') { + return ; + } +}; diff --git a/src/components/Shutdowns/ChartContainer.tsx b/src/components/Shutdowns/ChartContainer.tsx index 79d6724..9f37a5d 100644 --- a/src/components/Shutdowns/ChartContainer.tsx +++ b/src/components/Shutdowns/ChartContainer.tsx @@ -4,6 +4,7 @@ import { Shutdown } from '../../types'; import TravelTimesChart from '../charts/TravelTimesChart'; import { AggregateDataResponse } from '../charts/types'; import { useBreakpoint } from '../../hooks/useBreakpoint'; +import { getFormattedTimeValue } from '../../utils/time'; interface ChartContainerProps { shutdown: Shutdown; @@ -19,13 +20,13 @@ const ChartContainer = ({ before, after, shutdown, title }: ChartContainerProps) const beforeData = before.data!.by_date.filter((datapoint) => datapoint.peak === 'all'); const afterData = after.data!.by_date.filter((datapoint) => datapoint.peak === 'all'); - const beforeAvg = (beforeData.reduce((a, b) => a + b['50%'], 0) / beforeData.length / 60).toFixed( - 2 - ); + const beforeAvg = beforeData.reduce((a, b) => a + b['50%'], 0) / beforeData.length; + + const afterAvg = afterData.reduce((a, b) => a + b['50%'], 0) / afterData.length; - const afterAvg = (afterData.reduce((a, b) => a + b['50%'], 0) / afterData.length / 60).toFixed(2); + const difference = Number(afterAvg) - Number(beforeAvg); + const direction = !isNaN(difference) ? (beforeAvg > afterAvg ? 'down' : 'up') : undefined; - const difference = (Number(afterAvg) - Number(beforeAvg)).toFixed(2); return (
@@ -36,26 +37,26 @@ const ChartContainer = ({ before, after, shutdown, title }: ChartContainerProps)
-
+
{!isMobile ? 'Before' : 'Before shutdown'}
-
- {beforeAvg} +
+ {getFormattedTimeValue(beforeAvg, true)}
-
+
{' '} {!isMobile ? 'After' : 'After shutdown'}
-
- {afterAvg} +
+ {getFormattedTimeValue(afterAvg, true)}
-
Change
-
- {difference} +
Change
+
+ {getFormattedTimeValue(difference, true, direction)}
diff --git a/src/components/UnitText.tsx b/src/components/UnitText.tsx new file mode 100644 index 0000000..038c164 --- /dev/null +++ b/src/components/UnitText.tsx @@ -0,0 +1,15 @@ +import classNames from 'classnames'; +import React from 'react'; + +interface UnitTextProps { + text: string; + isLarge?: boolean; +} + +export const UnitText: React.FC = ({ text, isLarge = false }) => { + return ( + + {text} + + ); +}; diff --git a/src/components/WidgetText.tsx b/src/components/WidgetText.tsx new file mode 100644 index 0000000..ab8c332 --- /dev/null +++ b/src/components/WidgetText.tsx @@ -0,0 +1,15 @@ +import classNames from 'classnames'; +import React from 'react'; + +interface WidgetTextProps { + text: string; + isLarge?: boolean; +} + +export const WidgetText: React.FC = ({ text, isLarge = false }) => { + return ( + + {text} + + ); +}; diff --git a/src/components/charts/AggregateLineChart.tsx b/src/components/charts/AggregateLineChart.tsx index f17f3b5..0075534 100644 --- a/src/components/charts/AggregateLineChart.tsx +++ b/src/components/charts/AggregateLineChart.tsx @@ -11,8 +11,8 @@ import { writeError } from '../../utils/chartError'; import { CHART_COLORS } from '../../constants/colors'; import { watermarkLayout } from '../../utils/watermark'; import { AggregateDataPoint } from '../../api/types'; -import { AggregateLineProps } from './types'; import { useStore } from '../../store'; +import { AggregateLineProps } from './types'; const xAxisLabel = (startDate: string, endDate: string, hourly: boolean) => { if (hourly) { @@ -156,6 +156,8 @@ export const AggregateLineChart: React.FC = ({ afterDraw: (chart: ChartJS) => { if (startDate === undefined || endDate === undefined || beforeData.length === 0) { writeError(chart); + } else if (afterData.length === 0 || afterData.length < 7) { + writeError(chart, 'Analysis still in progress, numbers not final.'); } }, }, diff --git a/src/components/charts/TravelTimesChart.tsx b/src/components/charts/TravelTimesChart.tsx index 93b42c4..218feb0 100644 --- a/src/components/charts/TravelTimesChart.tsx +++ b/src/components/charts/TravelTimesChart.tsx @@ -1,9 +1,9 @@ -import { AggregateLineChart } from './AggregateLineChart'; +import dayjs from 'dayjs'; import { Shutdown } from '../../types'; -import { AggregateDataPoint, PointFieldKeys } from './types'; import { CHART_COLORS } from '../../constants/colors'; import { getLocationDetails } from '../../utils/stations'; -import dayjs from 'dayjs'; +import { AggregateLineChart } from './AggregateLineChart'; +import { AggregateDataPoint, PointFieldKeys } from './types'; interface TravelTimesChartProps { shutdown: Shutdown; diff --git a/src/constants/colors.ts b/src/constants/colors.ts index 592f454..238c3bd 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -1,10 +1,8 @@ - export const hexWithAlpha = (hexColor: string, alpha: number) => { const opacity = Math.round(Math.min(Math.max(alpha || 1, 0), 1) * 255); return hexColor + opacity.toString(16).toUpperCase(); }; - export const COLORS = { mbta: { red: '#D13434', @@ -45,4 +43,3 @@ export const CHART_COLORS = { ANNOTATIONS: hexWithAlpha('#202020', 0.4), BLOCKS: hexWithAlpha('#202020', 0.2), }; - diff --git a/src/constants/styles.ts b/src/constants/styles.ts index 52050a5..b8eb5a7 100644 --- a/src/constants/styles.ts +++ b/src/constants/styles.ts @@ -1 +1 @@ -export const cardStyles = 'rounded-lg bg-white dark:dark:bg-slate-700 dark:text-white p-4 shadow' +export const cardStyles = 'rounded-lg bg-white dark:dark:bg-slate-700 dark:text-white p-4 shadow'; diff --git a/src/utils/chartError.ts b/src/utils/chartError.ts index 2a23692..68fd975 100644 --- a/src/utils/chartError.ts +++ b/src/utils/chartError.ts @@ -1,12 +1,10 @@ -const errorMsg = 'No data available. Try another stop or date.'; const txtColor = 'gray'; function font(size_px = 16) { return `bold ${size_px}px "Helvetica Neue", "Helvetica", "Arial", sans-serif`; } - -export const writeError = (chart) => { +export const writeError = (chart, errorMsg = 'No data available. Try another stop or date.') => { const { ctx } = chart; ctx.save(); diff --git a/src/utils/date.ts b/src/utils/date.ts index b4c8067..a9d440f 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -21,7 +21,6 @@ export const prettyDate = (dateString: string, withDow: boolean) => { ); }; - export const getFormattedTimeString = (value: number, unit: 'minutes' | 'seconds' = 'seconds') => { const secondsValue = unit === 'seconds' ? value : value * 60; const absValue = Math.round(Math.abs(secondsValue)); diff --git a/src/utils/time.tsx b/src/utils/time.tsx new file mode 100644 index 0000000..3c8b3af --- /dev/null +++ b/src/utils/time.tsx @@ -0,0 +1,128 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { WidgetText } from '../components/WidgetText'; +import { UnitText } from '../components/UnitText'; +import { DirectionIndicator } from '../components/DirectionIndicator'; + +dayjs.extend(duration); + +export const getTimeUnit = (value: number) => { + const secondsAbs = Math.abs(value); + switch (true) { + case secondsAbs < 100: + return 'sec'; + case secondsAbs < 3600: + return 'min'; + default: + return 'hrs'; + } +}; + +export const getFormattedTimeValue = ( + value: number, + isLarge?: boolean, + direction?: 'up' | 'down' +) => { + const absValue = Math.round(Math.abs(value)); + const duration = dayjs.duration(absValue, 'seconds'); + + if (isNaN(absValue)) { + return ( +

+ + +

+ ); + } + + switch (true) { + case absValue < 100: + return ( +

+ + + +

+ ); + case absValue < 3600: + return ( +

+ + {' '} + + + +

+ ); + default: + return ( +

+ + {' '} + + + +

+ ); + } +}; + +export const getFormattedTimeString = (value: number, unit: 'minutes' | 'seconds' = 'seconds') => { + const secondsValue = unit === 'seconds' ? value : value * 60; + const absValue = Math.round(Math.abs(secondsValue)); + const duration = dayjs.duration(absValue, 'seconds'); + switch (true) { + case absValue < 100: + return `${absValue}s`; + case absValue < 3600: + return `${duration.format('m')}m ${duration.format('s').padStart(2, '0')}s`; + default: + return `${duration.format('H')}h ${duration.format('m').padStart(2, '0')}m`; + } +}; + +interface GetClockFormattedTimeStringOptions { + truncateLeadingZeros?: boolean; + showSeconds?: boolean; + showHours?: boolean; + use12Hour?: boolean; +} + +export const getClockFormattedTimeString = ( + time: number, + options: GetClockFormattedTimeStringOptions = {} +): string => { + time = Math.round(time); + const { + truncateLeadingZeros = true, + showSeconds = false, + showHours = true, + use12Hour = false, + } = options; + let seconds = time, + minutes = 0, + hours = 0; + const minutesToAdd = Math.floor(seconds / 60); + seconds = seconds % 60; + minutes = minutes += minutesToAdd; + const hoursToAdd = Math.floor(minutes / 60); + minutes = minutes % 60; + hours += hoursToAdd; + const isPM = hours >= 12 && hours < 24; + hours = (use12Hour && hours > 12 ? hours - 12 : hours) % 24; + // eslint-disable-next-line prefer-const + let [hoursString, minutesString, secondsString] = [hours, minutes, seconds].map((num) => + num.toString().padStart(2, '0') + ); + let timeString = [hoursString, minutesString, secondsString] + .slice(showHours ? 0 : 1) + .slice(0, showSeconds ? 3 : 2) + .join(':'); + if (truncateLeadingZeros && timeString.startsWith('0')) { + timeString = timeString.slice(1); + } + if (use12Hour) { + return `${timeString} ${isPM ? 'PM' : 'AM'}`; + } + return timeString; +};