diff --git a/common/api/hooks/reliability.ts b/common/api/hooks/reliability.ts new file mode 100644 index 000000000..0759b64fa --- /dev/null +++ b/common/api/hooks/reliability.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { ONE_HOUR } from '../../constants/time'; +import { fetchLineDelaysByLine } from '../reliability'; +import type { FetchAlertDelaysByLineOptions } from '../../types/api'; + +export const useAlertDelays = (options: FetchAlertDelaysByLineOptions, enabled?: boolean) => { + return useQuery({ + queryKey: ['lineReliability', options], + queryFn: () => fetchLineDelaysByLine(options), + enabled: enabled, + staleTime: ONE_HOUR, + }); +}; diff --git a/common/api/reliability.ts b/common/api/reliability.ts new file mode 100644 index 000000000..0051579ae --- /dev/null +++ b/common/api/reliability.ts @@ -0,0 +1,15 @@ +import { FetchAlertDelaysByLineParams, type FetchAlertDelaysByLineOptions } from '../types/api'; +import type { LineDelays } from '../types/reliability'; +import { apiFetch } from './utils/fetch'; + +export const fetchLineDelaysByLine = async ( + options: FetchAlertDelaysByLineOptions +): Promise => { + if (!options[FetchAlertDelaysByLineParams.line]) return []; + + return await apiFetch({ + path: '/api/linedelays', + options, + errorMessage: 'Failed to fetch reliability metrics', + }); +}; diff --git a/common/components/charts/ChartDiv.tsx b/common/components/charts/ChartDiv.tsx index aa2ee83b2..97388d240 100644 --- a/common/components/charts/ChartDiv.tsx +++ b/common/components/charts/ChartDiv.tsx @@ -8,6 +8,6 @@ interface ChartDivProps { export const ChartDiv: React.FC = ({ children, isMobile = false }) => { return ( -
{children}
+
{children}
); }; diff --git a/common/constants/pages.ts b/common/constants/pages.ts index 69f7c7950..0119a6e42 100644 --- a/common/constants/pages.ts +++ b/common/constants/pages.ts @@ -1,7 +1,6 @@ import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { faMapLocationDot, - faCalendar, faHouse, faUsers, faWarning, @@ -9,6 +8,8 @@ import { faGaugeHigh, faTableColumns, faStopwatch20, + faCalendarDays, + faCalendarXmark, } from '@fortawesome/free-solid-svg-icons'; import type { Line } from '../types/lines'; @@ -19,6 +20,7 @@ export enum PAGES { overview = 'overview', speed = 'speed', predictions = 'predictions', + reliability = 'reliability', service = 'service', slowzones = 'slowzones', systemSlowzones = 'systemSlowzones', @@ -77,7 +79,7 @@ export const ALL_PAGES: PageMap = { name: 'Multi-day trips', title: 'Multi-day trips', lines: ['line-red', 'line-blue', 'line-green', 'line-orange', 'line-bus'], - icon: faCalendar, + icon: faCalendarDays, dateStoreSection: 'multiTrips', hasStationStore: true, }, @@ -113,6 +115,14 @@ export const ALL_PAGES: PageMap = { dateStoreSection: 'line', icon: faClockFour, }, + reliability: { + key: 'reliability', + path: '/reliability', + name: 'Reliability', + lines: ['line-red', 'line-orange', 'line-blue', 'line-green'], + icon: faCalendarXmark, + dateStoreSection: 'line', + }, slowzones: { key: 'slowzones', path: '/slowzones', @@ -161,6 +171,7 @@ export const LINE_PAGES = [ ALL_PAGES.slowzones, ALL_PAGES.speed, ALL_PAGES.predictions, + ALL_PAGES.reliability, ALL_PAGES.ridership, ]; diff --git a/common/types/api.ts b/common/types/api.ts index 119efb892..05483f5c1 100644 --- a/common/types/api.ts +++ b/common/types/api.ts @@ -51,6 +51,12 @@ export type FetchDeliveredTripMetricsOptions = { line?: Line; }; +export type FetchAlertDelaysByLineOptions = { + start_date?: string; + end_date?: string; + line?: Line; +}; + export enum FetchDeliveredTripMetricsParams { startDate = 'start_date', endDate = 'end_date', @@ -58,6 +64,12 @@ export enum FetchDeliveredTripMetricsParams { line = 'line', } +export enum FetchAlertDelaysByLineParams { + startDate = 'start_date', + endDate = 'end_date', + line = 'line', +} + export enum FetchSpeedsParams { startDate = 'start_date', endDate = 'end_date', diff --git a/common/types/reliability.ts b/common/types/reliability.ts new file mode 100644 index 000000000..fc4d7512f --- /dev/null +++ b/common/types/reliability.ts @@ -0,0 +1,19 @@ +import type { Line } from './lines'; + +export interface LineDelays { + date: string; + disabled_train: number; + door_problem: number; + flooding: number; + fire: number; + line: Line; + medical_emergency: number; + other: number; + police_activity: number; + power_problem: number; + signal_problem: number; + brake_problem: number; + switch_problem: number; + total_delay_time: number; + track_issue: number; +} diff --git a/common/utils/time.tsx b/common/utils/time.tsx index 3ecb90274..6ff36dd2a 100644 --- a/common/utils/time.tsx +++ b/common/utils/time.tsx @@ -55,11 +55,14 @@ export const getFormattedTimeString = (value: number, unit: 'minutes' | 'seconds const secondsValue = unit === 'seconds' ? value : value * 60; const absValue = Math.round(Math.abs(secondsValue)); const duration = dayjs.duration(absValue, 'seconds'); + const hoursDuration = duration.asHours(); switch (true) { case absValue < 100: return `${absValue}s`; case absValue < 3600: return `${duration.format('m')}m ${duration.format('s').padStart(2, '0')}s`; + case absValue > 86400: + return `${hoursDuration.toFixed(0)}h ${duration.format('m').padStart(2, '0')}m`; default: return `${duration.format('H')}h ${duration.format('m').padStart(2, '0')}m`; } diff --git a/modules/navigation/SidebarTabs.tsx b/modules/navigation/SidebarTabs.tsx index fd1731c64..723e7a3b2 100644 --- a/modules/navigation/SidebarTabs.tsx +++ b/modules/navigation/SidebarTabs.tsx @@ -45,7 +45,7 @@ export const SidebarTabs: React.FC = ({ tabs, close }) => { selected ? 'bg-stone-900 text-white' : enabled && 'text-stone-300 hover:bg-stone-800 hover:text-white', - 'group flex select-none items-center gap-x-3 rounded-sm py-2 pl-2 text-sm font-semibold leading-6', + 'group flex select-none items-center gap-x-3 rounded-sm py-1.5 pl-2 text-sm font-semibold leading-6', enabled ? 'cursor-pointer' : 'cursor-default text-stone-600' )} > diff --git a/modules/reliability/ReliabilityDetails.tsx b/modules/reliability/ReliabilityDetails.tsx new file mode 100644 index 000000000..3266a0758 --- /dev/null +++ b/modules/reliability/ReliabilityDetails.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import { useDelimitatedRoute } from '../../common/utils/router'; +import { ChartPlaceHolder } from '../../common/components/graphics/ChartPlaceHolder'; +import { Layout } from '../../common/layouts/layoutTypes'; +import { PageWrapper } from '../../common/layouts/PageWrapper'; +import { ChartPageDiv } from '../../common/components/charts/ChartPageDiv'; +import { useAlertDelays } from '../../common/api/hooks/reliability'; +import { Widget } from '../../common/components/widgets'; +import { TotalDelayGraph } from './charts/TotalDelayGraph'; +import { DelayBreakdownGraph } from './charts/DelayBreakdownGraph'; +import { DelayByCategoryGraph } from './charts/DelayByCategoryGraph'; + +dayjs.extend(utc); + +export function ReliabilityDetails() { + const { + line, + query: { startDate, endDate }, + } = useDelimitatedRoute(); + + const enabled = Boolean(startDate && endDate && line); + const alertDelays = useAlertDelays( + { + start_date: startDate, + end_date: endDate, + line, + }, + enabled + ); + const reliabilityReady = alertDelays && line && !alertDelays.isError && alertDelays.data; + if (!startDate || !endDate) { + return

Select a date range to load graphs.

; + } + + return ( + + + + {reliabilityReady ? ( + + ) : ( +
+ +
+ )} +
+ + {reliabilityReady ? ( + + ) : ( +
+ +
+ )} +
+ + {reliabilityReady ? ( + + ) : ( +
+ +
+ )} +
+
+
+ ); +} + +ReliabilityDetails.Layout = Layout.Dashboard; diff --git a/modules/reliability/charts/DelayBreakdownGraph.tsx b/modules/reliability/charts/DelayBreakdownGraph.tsx new file mode 100644 index 000000000..aa49f2f69 --- /dev/null +++ b/modules/reliability/charts/DelayBreakdownGraph.tsx @@ -0,0 +1,294 @@ +import React, { useRef } from 'react'; +import { Line } from 'react-chartjs-2'; + +import 'chartjs-adapter-date-fns'; +import { enUS } from 'date-fns/locale'; + +import ChartjsPluginWatermark from 'chartjs-plugin-watermark'; +import classNames from 'classnames'; +import { useDelimitatedRoute } from '../../../common/utils/router'; +import { COLORS, LINE_COLORS } from '../../../common/constants/colors'; +import { drawSimpleTitle } from '../../../common/components/charts/Title'; +import { useBreakpoint } from '../../../common/hooks/useBreakpoint'; +import { watermarkLayout } from '../../../common/constants/charts'; +import { ChartBorder } from '../../../common/components/charts/ChartBorder'; +import type { LineDelays } from '../../../common/types/reliability'; +import { getFormattedTimeString } from '../../../common/utils/time'; +import { hexWithAlpha } from '../../../common/utils/general'; + +interface DelayBreakdownGraphProps { + data: LineDelays[]; + startDate: string; + endDate: string; + showTitle?: boolean; +} + +export const DelayBreakdownGraph: React.FC = ({ + data, + startDate, + endDate, + showTitle = false, +}) => { + const { line, linePath } = useDelimitatedRoute(); + const ref = useRef(); + const isMobile = !useBreakpoint('md'); + const labels = data.map((point) => point.date); + + const lineColor = LINE_COLORS[line ?? 'default']; + + return ( + +
+ datapoint.disabled_train), + }, + { + label: `Door Problem`, + borderColor: '#3f6212', + backgroundColor: hexWithAlpha('#3f6212', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.door_problem), + }, + { + label: `Power/Wire Issue`, + borderColor: '#eab308', + backgroundColor: hexWithAlpha('#eab308', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.power_problem), + }, + { + label: `Signal Problem`, + borderColor: '#84cc16', + backgroundColor: hexWithAlpha('#84cc16', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.signal_problem), + }, + { + label: `Switch Problem`, + borderColor: '#10b981', + backgroundColor: hexWithAlpha('#10b981', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.switch_problem), + }, + { + label: `Brake Issue`, + borderColor: '#4c1d95', + backgroundColor: hexWithAlpha('#4c1d95', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.brake_problem), + }, + { + label: `Track Issue`, + borderColor: '#8b5cf6', + backgroundColor: hexWithAlpha('#8b5cf6', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.track_issue), + }, + { + label: `Flooding`, + borderColor: '#0ea5e9', + backgroundColor: hexWithAlpha('#0ea5e9', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.flooding), + }, + { + label: `Police Activity`, + borderColor: '#1d4ed8', + backgroundColor: hexWithAlpha('#1d4ed8', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.police_activity), + }, + { + label: `Medical Emergency`, + borderColor: '#be123c', + backgroundColor: hexWithAlpha('#be123c', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.medical_emergency), + }, + { + label: `Fire Department Activity`, + borderColor: '#ea580c', + backgroundColor: hexWithAlpha('#ea580c', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.fire), + }, + { + label: `Other`, + borderColor: '#6b7280', + backgroundColor: hexWithAlpha('#6b7280', 0.8), + pointRadius: 0, + pointBorderWidth: 0, + fill: true, + pointHoverRadius: 6, + pointHoverBackgroundColor: lineColor, + data: data.map((datapoint) => datapoint.other), + }, + ], + }} + options={{ + responsive: true, + maintainAspectRatio: false, + layout: { + padding: { + top: showTitle ? 25 : 0, + }, + }, + interaction: { + intersect: false, + }, + // @ts-expect-error The watermark plugin doesn't have typescript support + watermark: watermarkLayout(isMobile), + plugins: { + tooltip: { + mode: 'index', + position: 'nearest', + callbacks: { + title: (context) => { + return `Week of ${context[0].label}`; + }, + label: (tooltipItem) => { + return `${tooltipItem.dataset.label}: ${getFormattedTimeString( + tooltipItem.parsed.y, + 'minutes' + )}`; + }, + }, + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 15, + }, + }, + title: { + // empty title to set font and leave room for drawTitle fn + display: showTitle, + text: '', + }, + }, + scales: { + y: { + stacked: true, + suggestedMin: 0, + min: 0, + display: true, + ticks: { + color: COLORS.design.subtitleGrey, + }, + title: { + display: true, + text: 'Time delayed (minutes)', + color: COLORS.design.subtitleGrey, + }, + }, + x: { + min: startDate, + max: endDate, + type: 'time', + time: { + unit: 'day', + tooltipFormat: 'MMM d, yyyy', + displayFormats: { + month: 'MMM', + }, + }, + ticks: { + color: COLORS.design.subtitleGrey, + }, + adapters: { + date: { + locale: enUS, + }, + }, + display: true, + title: { + display: false, + text: ``, + }, + }, + }, + }} + plugins={[ + { + id: 'customTitle', + afterDraw: (chart) => { + if (!data) { + // No data is present + const { ctx } = chart; + const { width } = chart; + const { height } = chart; + chart.clear(); + + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = "16px normal 'Helvetica Nueue'"; + ctx.fillText('No data to display', width / 2, height / 2); + ctx.restore(); + } + if (showTitle) drawSimpleTitle(`Speed`, chart); + }, + }, + ChartjsPluginWatermark, + ]} + /> +
+
+ ); +}; diff --git a/modules/reliability/charts/DelayByCategoryGraph.tsx b/modules/reliability/charts/DelayByCategoryGraph.tsx new file mode 100644 index 000000000..2d9f0c0a0 --- /dev/null +++ b/modules/reliability/charts/DelayByCategoryGraph.tsx @@ -0,0 +1,197 @@ +import React, { useRef } from 'react'; +import { Bar } from 'react-chartjs-2'; + +import 'chartjs-adapter-date-fns'; + +import ChartjsPluginWatermark from 'chartjs-plugin-watermark'; +import classNames from 'classnames'; +import { useDelimitatedRoute } from '../../../common/utils/router'; +import { COLORS } from '../../../common/constants/colors'; +import { drawSimpleTitle } from '../../../common/components/charts/Title'; +import { useBreakpoint } from '../../../common/hooks/useBreakpoint'; +import { watermarkLayout } from '../../../common/constants/charts'; +import { ChartBorder } from '../../../common/components/charts/ChartBorder'; +import type { LineDelays } from '../../../common/types/reliability'; +import { getFormattedTimeString } from '../../../common/utils/time'; + +interface DelayByCategoryGraphProps { + data: LineDelays[]; + showTitle?: boolean; +} + +const sumArray = (array: number[][]) => { + const newArray: number[] = []; + array.forEach((sub) => { + sub.forEach((num, index) => { + if (newArray[index]) { + newArray[index] += num; + } else { + newArray[index] = num; + } + }); + }); + return newArray; +}; + +export const DelayByCategoryGraph: React.FC = ({ + data, + showTitle = false, +}) => { + const { linePath } = useDelimitatedRoute(); + const ref = useRef(); + const isMobile = !useBreakpoint('md'); + const labels = [ + '🚉 Disabled Train', + '🚪 Door Problem', + '🔌 Power/Wire Issue', + '🚦 Signal Problem', + '🎚️ Switch Problem', + '🛑 Brake Issue', + '🛤️ Track Issue', + '🌊 Flooding', + '🚓 Police Activity', + '🚑 Medical Emergency', + '🚒 Fire Department Activity', + 'Other', + ]; + const backgroundColors = [ + '#dc2626', + '#3f6212', + '#eab308', + '#84cc16', + '#10b981', + '#4c1d95', + '#8b5cf6', + '#0ea5e9', + '#1d4ed8', + '#be123c', + '#ea580c', + '#6b7280', + ]; + const delayTotals: number[] = sumArray( + data.map((datapoint) => { + return [ + datapoint.disabled_train, + datapoint.door_problem, + datapoint.power_problem, + datapoint.signal_problem, + datapoint.switch_problem, + datapoint.brake_problem, + datapoint.track_issue, + datapoint.flooding, + datapoint.police_activity, + datapoint.medical_emergency, + datapoint.fire, + datapoint.other, + ]; + }) + ); + + return ( + +
+ { + return `${tooltipItem.label} total delay: ${getFormattedTimeString( + tooltipItem.parsed.y, + 'minutes' + )}`; + }, + }, + }, + legend: { + display: false, + }, + title: { + // empty title to set font and leave room for drawTitle fn + display: showTitle, + text: '', + }, + }, + scales: { + y: { + suggestedMin: 0, + min: 0, + beginAtZero: true, + display: true, + ticks: { + color: COLORS.design.subtitleGrey, + }, + title: { + display: true, + text: 'Time delayed (minutes)', + color: COLORS.design.subtitleGrey, + }, + }, + x: { + ticks: { + color: COLORS.design.subtitleGrey, + }, + display: true, + title: { + display: false, + text: ``, + }, + }, + }, + }} + plugins={[ + { + id: 'customTitle', + afterDraw: (chart) => { + if (!data) { + // No data is present + const { ctx } = chart; + const { width } = chart; + const { height } = chart; + chart.clear(); + + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = "16px normal 'Helvetica Nueue'"; + ctx.fillText('No data to display', width / 2, height / 2); + ctx.restore(); + } + if (showTitle) drawSimpleTitle(`Speed`, chart); + }, + }, + ChartjsPluginWatermark, + ]} + /> +
+
+ ); +}; diff --git a/modules/reliability/charts/TotalDelayGraph.tsx b/modules/reliability/charts/TotalDelayGraph.tsx new file mode 100644 index 000000000..d0f081fb4 --- /dev/null +++ b/modules/reliability/charts/TotalDelayGraph.tsx @@ -0,0 +1,169 @@ +import React, { useRef } from 'react'; +import { Line } from 'react-chartjs-2'; + +import 'chartjs-adapter-date-fns'; +import { enUS } from 'date-fns/locale'; + +import ChartjsPluginWatermark from 'chartjs-plugin-watermark'; +import { useDelimitatedRoute } from '../../../common/utils/router'; +import { COLORS, LINE_COLORS } from '../../../common/constants/colors'; +import { drawSimpleTitle } from '../../../common/components/charts/Title'; +import { useBreakpoint } from '../../../common/hooks/useBreakpoint'; +import { watermarkLayout } from '../../../common/constants/charts'; +import { ChartBorder } from '../../../common/components/charts/ChartBorder'; +import { ChartDiv } from '../../../common/components/charts/ChartDiv'; +import type { LineDelays } from '../../../common/types/reliability'; +import { getFormattedTimeString } from '../../../common/utils/time'; +import { hexWithAlpha } from '../../../common/utils/general'; + +interface TotalDelayGraphProps { + data: LineDelays[]; + startDate: string; + endDate: string; + showTitle?: boolean; +} + +export const TotalDelayGraph: React.FC = ({ + data, + startDate, + endDate, + showTitle = false, +}) => { + const { line, linePath } = useDelimitatedRoute(); + const ref = useRef(); + const isMobile = !useBreakpoint('md'); + const labels = data.map((point) => point.date); + + const lineColor = LINE_COLORS[line ?? 'default']; + + return ( + + + datapoint.total_delay_time), + }, + ], + }} + options={{ + responsive: true, + maintainAspectRatio: false, + layout: { + padding: { + top: showTitle ? 25 : 0, + }, + }, + interaction: { + intersect: false, + }, + // @ts-expect-error The watermark plugin doesn't have typescript support + watermark: watermarkLayout(isMobile), + plugins: { + tooltip: { + mode: 'index', + position: 'nearest', + callbacks: { + title: (context) => { + return `Week of ${context[0].label}`; + }, + label: (tooltipItem) => { + return `${tooltipItem.dataset.label}: ${getFormattedTimeString( + tooltipItem.parsed.y, + 'minutes' + )}`; + }, + }, + }, + legend: { + display: false, + }, + title: { + // empty title to set font and leave room for drawTitle fn + display: showTitle, + text: '', + }, + }, + scales: { + y: { + suggestedMin: 0, + min: 0, + display: true, + ticks: { + color: COLORS.design.subtitleGrey, + }, + title: { + display: true, + text: 'Total time delayed (minutes)', + color: COLORS.design.subtitleGrey, + }, + }, + x: { + min: startDate, + max: endDate, + type: 'time', + time: { + unit: 'day', + tooltipFormat: 'MMM d, yyyy', + displayFormats: { + month: 'MMM', + }, + }, + ticks: { + color: COLORS.design.subtitleGrey, + }, + adapters: { + date: { + locale: enUS, + }, + }, + display: true, + title: { + display: false, + text: ``, + }, + }, + }, + }} + plugins={[ + { + id: 'customTitle', + afterDraw: (chart) => { + if (!data) { + // No data is present + const { ctx } = chart; + const { width } = chart; + const { height } = chart; + chart.clear(); + + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = "16px normal 'Helvetica Nueue'"; + ctx.fillText('No data to display', width / 2, height / 2); + ctx.restore(); + } + if (showTitle) drawSimpleTitle(`Speed`, chart); + }, + }, + ChartjsPluginWatermark, + ]} + /> + + + ); +}; diff --git a/pages/[line]/reliability.tsx b/pages/[line]/reliability.tsx new file mode 100644 index 000000000..77061cb6a --- /dev/null +++ b/pages/[line]/reliability.tsx @@ -0,0 +1,15 @@ +import { ALL_LINE_PATHS } from '../../common/types/lines'; +import { ReliabilityDetails } from '../../modules/reliability/ReliabilityDetails'; + +export async function getStaticProps() { + return { props: {} }; +} + +export async function getStaticPaths() { + return { + paths: ALL_LINE_PATHS, + fallback: false, + }; +} + +export default ReliabilityDetails; diff --git a/server/app.py b/server/app.py index 661e37c09..3775dd67b 100644 --- a/server/app.py +++ b/server/app.py @@ -8,6 +8,7 @@ aggregation, data_funcs, secrets, + reliability, mbta_v3, speed, speed_restrictions, @@ -164,6 +165,12 @@ def get_alerts(): return json.dumps(response, indent=4, sort_keys=True, default=str) +@app.route("/api/linedelays", cors=cors_config) +def get_delays_by_line(): + response = reliability.delay_time_by_line(app.current_request.query_params) + return json.dumps(response, indent=4, sort_keys=True) + + @app.route("/api/tripmetrics", cors=cors_config) def get_trips_by_line(): response = speed.trip_metrics_by_line(app.current_request.query_params) diff --git a/server/chalicelib/constants.py b/server/chalicelib/constants.py index 4f9403195..1801fe0db 100644 --- a/server/chalicelib/constants.py +++ b/server/chalicelib/constants.py @@ -1,6 +1,8 @@ EVENT_ARRIVAL = ["ARR", "PRA"] EVENT_DEPARTURE = ["DEP", "PRD"] +DATE_FORMAT_BACKEND = "%Y-%m-%d" + LINE_TO_ROUTE_MAP = { "line-red": ["line-red-a", "line-red-b"], "line-green": ["line-green-b", "line-green-c", "line-green-d", "line-green-e"], diff --git a/server/chalicelib/reliability.py b/server/chalicelib/reliability.py new file mode 100644 index 000000000..3faf7400d --- /dev/null +++ b/server/chalicelib/reliability.py @@ -0,0 +1,38 @@ +from typing import TypedDict +from chalice import BadRequestError, ForbiddenError +from chalicelib import dynamo +from datetime import date, datetime, timedelta +from chalicelib.constants import DATE_FORMAT_BACKEND + + +TABLE_NAME = "AlertDelaysWeekly" +MAX_DELTA = 1000 + + +class AlertDelaysByLineParams(TypedDict): + start_date: str | date + end_date: str | date + line: str + + +def is_invalid_range(start_date, end_date, max_delta): + """Check if number of requested entries is more than maximum for the table""" + start_datetime = datetime.strptime(start_date, DATE_FORMAT_BACKEND) + end_datetime = datetime.strptime(end_date, DATE_FORMAT_BACKEND) + return start_datetime + timedelta(days=max_delta) < end_datetime + + +def delay_time_by_line(params: AlertDelaysByLineParams): + try: + start_date = params["start_date"] + end_date = params["end_date"] + line = params["line"] + if line not in ["line-red", "line-blue", "line-green", "line-orange"]: + raise BadRequestError("Invalid Line key.") + except KeyError: + raise BadRequestError("Missing or invalid parameters.") + # Prevent queries of more than 1000 items. + if is_invalid_range(start_date, end_date, MAX_DELTA): + raise ForbiddenError("Date range too long. The maximum number of requested values is 150.") + # If querying for weekly/monthly data, can just return the query. + return dynamo.query_agg_trip_metrics(start_date, end_date, TABLE_NAME, line) diff --git a/server/chalicelib/speed.py b/server/chalicelib/speed.py index 0e00bd544..27ab0e367 100644 --- a/server/chalicelib/speed.py +++ b/server/chalicelib/speed.py @@ -4,6 +4,7 @@ from datetime import date, datetime, timedelta import pandas as pd import numpy as np +from chalicelib.constants import DATE_FORMAT_BACKEND class TripMetricsByLineParams(TypedDict): @@ -20,8 +21,6 @@ class TripMetricsByLineParams(TypedDict): "monthly": {"table_name": "DeliveredTripMetricsMonthly", "delta": 30 * 150}, } -DATE_FORMAT_BACKEND = "%Y-%m-%d" - def aggregate_actual_trips(actual_trips, agg, start_date): flat_data = [entry for sublist in actual_trips for entry in sublist]