diff --git a/common/api/hooks/slowzones.ts b/common/api/hooks/slowzones.ts index ed3e78679..7640784d1 100644 --- a/common/api/hooks/slowzones.ts +++ b/common/api/hooks/slowzones.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { fetchAllSlow, fetchDelayTotals, fetchSpeedRestrictions } from '../slowzones'; import { ONE_HOUR } from '../../constants/time'; +import type { FetchSpeedRestrictionsOptions } from '../../types/api'; export const useSlowzoneAllData = () => { return useQuery(['allSlow'], fetchAllSlow, { staleTime: ONE_HOUR }); @@ -10,6 +11,9 @@ export const useSlowzoneDelayTotalData = () => { return useQuery(['delayTotals'], fetchDelayTotals, { staleTime: ONE_HOUR }); }; -export const useSpeedRestrictionData = () => { - return useQuery(['speedRestrictions'], fetchSpeedRestrictions); +export const useSpeedRestrictionData = (options: FetchSpeedRestrictionsOptions) => { + return useQuery(['speedRestrictions', options], () => fetchSpeedRestrictions(options), { + enabled: true, + staleTime: ONE_HOUR, + }); }; diff --git a/common/api/slowzones.ts b/common/api/slowzones.ts index b463ff2bb..670bf7688 100644 --- a/common/api/slowzones.ts +++ b/common/api/slowzones.ts @@ -3,6 +3,8 @@ import type { SlowZoneResponse, SpeedRestriction, } from '../../common/types/dataPoints'; +import type { FetchSpeedRestrictionsOptions } from '../types/api'; +import { getGtfsRailLineId } from '../utils/lines'; export const fetchDelayTotals = (): Promise => { const url = new URL(`/static/slowzones/delay_totals.json`, window.location.origin); @@ -14,10 +16,20 @@ export const fetchAllSlow = (): Promise => { return fetch(all_slow_url.toString()).then((resp) => resp.json()); }; -export const fetchSpeedRestrictions = (): Promise => { - const speed_restrictions_url = new URL( - `/static/slowzones/speed_restrictions.json`, +export const fetchSpeedRestrictions = async ( + options: FetchSpeedRestrictionsOptions +): Promise => { + const { lineId, date: requestedDate } = options; + const params = new URLSearchParams({ line_id: getGtfsRailLineId(lineId), date: requestedDate }); + const speedRestrictionsUrl = new URL( + '/api/speed_restrictions?' + params.toString(), window.location.origin ); - return fetch(speed_restrictions_url.toString()).then((resp) => resp.json()); + const today = new Date(); + const response = await fetch(speedRestrictionsUrl.toString()); + const { available, date: resolvedDate, zones } = await response.json(); + if (available) { + return zones.map((zone) => ({ ...zone, currentAsOf: resolvedDate, validAsOf: today })); + } + return []; }; diff --git a/common/types/api.ts b/common/types/api.ts index 91e16402e..c7a83d32b 100644 --- a/common/types/api.ts +++ b/common/types/api.ts @@ -83,3 +83,14 @@ export enum FetchRidershipParams { startDate = 'start_date', endDate = 'end_date', } + +export type FetchSpeedRestrictionsOptions = { + lineId: Line; + date: string; +}; + +export type FetchSpeedRestrictionsResponse = { + available: boolean; + date: string; + zones: Record[]; +}; diff --git a/common/types/dataPoints.ts b/common/types/dataPoints.ts index fc7467159..f4507f2bb 100644 --- a/common/types/dataPoints.ts +++ b/common/types/dataPoints.ts @@ -100,13 +100,14 @@ export type SpeedRestriction = { line: Exclude; description: string; reason: string; - status: string; fromStopId: null | string; toStopId: null | string; reported: string; - cleared: string; speedMph: number; trackFeet: number; + currentAsOf: Date; + lineId: Line; + validAsOf: Date; }; export type DayKind = 'weekday' | 'saturday' | 'sunday'; diff --git a/common/types/lines.ts b/common/types/lines.ts index bb577316d..a3a033f9e 100644 --- a/common/types/lines.ts +++ b/common/types/lines.ts @@ -33,6 +33,9 @@ export const RIDERSHIP_KEYS = { 'line-green': 'line-Green', }; +export const GTFS_COLOR_LINE_IDS = ['line-Red', 'line-Orange', 'line-Blue', 'line-Green'] as const; +export type GtfsColorLineId = (typeof GTFS_COLOR_LINE_IDS)[number]; + export const HEAVY_RAIL_LINES: Line[] = ['line-red', 'line-orange', 'line-blue']; export const LANDING_RAIL_LINES: Line[] = ['line-red', 'line-orange', 'line-blue', 'line-green']; diff --git a/common/utils/date.ts b/common/utils/date.ts index 67bf0ff11..6622eb1eb 100644 --- a/common/utils/date.ts +++ b/common/utils/date.ts @@ -6,7 +6,9 @@ export const prettyDate = (dateString: string, withDow: boolean) => { weekday: withDow ? 'long' : undefined, }; - const fullDate = dateString.includes('T') ? dateString : `${dateString}T00:00:00`; + const fullDate = dateString.includes('T') + ? dateString + : /* Offset so that it's always past midnight in Boston */ `${dateString}T07:00:00`; return new Date(fullDate).toLocaleDateString( undefined, // user locale/language diff --git a/common/utils/lines.ts b/common/utils/lines.ts index bc7feff92..553f797b6 100644 --- a/common/utils/lines.ts +++ b/common/utils/lines.ts @@ -1,7 +1,13 @@ -import { LINE_OBJECTS } from '../constants/lines'; -import type { LineShort } from '../types/lines'; +import type { GtfsColorLineId, Line, LineShort } from '../types/lines'; +import { RAIL_LINES } from '../types/lines'; -export const shortToLine = (name: LineShort): Exclude => { - const found = Object.entries(LINE_OBJECTS).find(([, line]) => line.short === name); - return found?.[0] as Exclude; +export const getGtfsRailLineId = (name: Line | LineShort): GtfsColorLineId => { + const normalizedColorPart = ( + name.includes('line-') ? name.replace('line-', '') : name + ).toLowerCase(); + if (!RAIL_LINES.includes(normalizedColorPart)) { + throw new Error('Not a valid rail line ID'); + } + const capped = normalizedColorPart.slice(0, 1).toUpperCase() + normalizedColorPart.slice(1); + return `line-${capped}` as GtfsColorLineId; }; diff --git a/modules/dashboard/Today.tsx b/modules/dashboard/Today.tsx index e04efd674..843629b9a 100644 --- a/modules/dashboard/Today.tsx +++ b/modules/dashboard/Today.tsx @@ -5,6 +5,7 @@ import { SlowZonesMap } from '../slowzones/map'; import { WidgetDiv } from '../../common/components/widgets/WidgetDiv'; import { useSlowzoneAllData, useSpeedRestrictionData } from '../../common/api/hooks/slowzones'; import { PageWrapper } from '../../common/layouts/PageWrapper'; +import type { Line } from '../../common/types/lines'; import { WidgetTitle } from './WidgetTitle'; interface TodayProps { @@ -13,7 +14,10 @@ interface TodayProps { export const Today: React.FC = ({ lineShort }) => { const allSlow = useSlowzoneAllData(); - const speedRestrictions = useSpeedRestrictionData(); + const speedRestrictions = useSpeedRestrictionData({ + lineId: `line-${lineShort.toLowerCase()}` as Line, + date: new Date().toISOString().split('T')[0], + }); const canShowSlowZonesMap = lineShort !== 'Green'; return ( diff --git a/modules/slowzones/SlowZonesDetails.tsx b/modules/slowzones/SlowZonesDetails.tsx index 6066593fd..f3706e9a3 100644 --- a/modules/slowzones/SlowZonesDetails.tsx +++ b/modules/slowzones/SlowZonesDetails.tsx @@ -25,10 +25,8 @@ import { SlowZonesWidgetTitle } from './SlowZonesWidgetTitle'; dayjs.extend(utc); export function SlowZonesDetails() { - const delayTotals = useSlowzoneDelayTotalData(); const [direction, setDirection] = useState('northbound'); - const allSlow = useSlowzoneAllData(); - const speedRestrictions = useSpeedRestrictionData(); + const { lineShort, linePath, @@ -36,6 +34,10 @@ export function SlowZonesDetails() { query: { startDate, endDate }, } = useDelimitatedRoute(); + const delayTotals = useSlowzoneDelayTotalData(); + const allSlow = useSlowzoneAllData(); + const speedRestrictions = useSpeedRestrictionData({ lineId: line!, date: endDate! }); + const startDateUTC = startDate ? dayjs.utc(startDate).startOf('day') : undefined; const endDateUTC = endDate ? dayjs.utc(endDate).startOf('day') : undefined; const totalSlowTimeReady = diff --git a/modules/slowzones/map/SlowZonesTooltip.tsx b/modules/slowzones/map/SlowZonesTooltip.tsx index 9c35c029a..5783c14a4 100644 --- a/modules/slowzones/map/SlowZonesTooltip.tsx +++ b/modules/slowzones/map/SlowZonesTooltip.tsx @@ -67,7 +67,7 @@ export const SlowZonesTooltip: React.FC = (props) => { const totalFeet = speedRestrictions.reduce((sum, sr) => sum + sr.trackFeet, 0); const [oldest] = speedRestrictions .filter((sr) => sr.reported) - .map((sr) => new Date(sr.reported).toISOString()) + .map((sr) => sr.reported) .sort(); const oldestString = prettyDate(oldest, false); return ( diff --git a/modules/slowzones/map/segment.ts b/modules/slowzones/map/segment.ts index 2f2e9ddad..66d1bdfe6 100644 --- a/modules/slowzones/map/segment.ts +++ b/modules/slowzones/map/segment.ts @@ -142,8 +142,8 @@ export const segmentSlowZones = (options: SegmentSlowZonesOptions): Segmentation speedRestrictions, lineName, effectiveDate, - (rs) => new Date(rs.cleared ?? '4000'), - (rs) => rs.line + (rs) => rs.validAsOf, + (rs) => rs.lineId.replace('line-', '') as LineShort ), (rs) => getStationById(rs.fromStopId!), (rs) => getStationById(rs.toStopId!) diff --git a/server/app.py b/server/app.py index 58486c7c3..44aee1a24 100644 --- a/server/app.py +++ b/server/app.py @@ -11,6 +11,7 @@ secrets, mbta_v3, speed, + speed_restrictions, service_levels, ridership, ) @@ -47,7 +48,9 @@ def healthcheck(): "API Key Present": (lambda: len(secrets.MBTA_V2_API_KEY) > 0), "S3 Headway Fetching": ( lambda: "2020-11-07 10:33:40" - in json.dumps(data_funcs.headways(date(year=2020, month=11, day=7), ["70061"])) + in json.dumps( + data_funcs.headways(date(year=2020, month=11, day=7), ["70061"]) + ) ), "Performance API Check": ( lambda: MbtaPerformanceAPI.get_api_data( @@ -109,7 +112,9 @@ def traveltime_route(user_date): @app.route("/api/alerts/{user_date}", cors=cors_config) def alerts_route(user_date): date = parse_user_date(user_date) - return json.dumps(data_funcs.alerts(date, mutlidict_to_dict(app.current_request.query_params))) + return json.dumps( + data_funcs.alerts(date, mutlidict_to_dict(app.current_request.query_params)) + ) @app.route("/api/aggregate/traveltimes", cors=cors_config) @@ -158,7 +163,11 @@ def dwells_aggregate_route(): def get_git_id(): # Only do this on localhost if TM_FRONTEND_HOST == "localhost": - git_id = str(subprocess.check_output(["git", "describe", "--always", "--dirty", "--abbrev=10"]))[2:-3] + git_id = str( + subprocess.check_output( + ["git", "describe", "--always", "--dirty", "--abbrev=10"] + ) + )[2:-3] return json.dumps({"git_id": git_id}) else: raise ConflictError("Cannot get git id from serverless host") @@ -204,3 +213,15 @@ def get_ridership(): line_id=line_id, ) return json.dumps(response) + + +@app.route("/api/speed_restrictions", cors=cors_config) +def get_speed_restrictions(): + query = app.current_request.query_params + on_date = query["date"] + line_id = query["line_id"] + response = speed_restrictions.query_speed_restrictions( + line_id=line_id, + on_date=on_date, + ) + return json.dumps(response) diff --git a/server/chalicelib/speed_restrictions.py b/server/chalicelib/speed_restrictions.py new file mode 100644 index 000000000..4a7ac5be8 --- /dev/null +++ b/server/chalicelib/speed_restrictions.py @@ -0,0 +1,36 @@ +from boto3.dynamodb.conditions import Key +from dynamodb_json import json_util as ddb_json + +from .dynamo import dynamodb + +SpeedRestrictions = dynamodb.Table("SpeedRestrictions") + + +def get_boundary_date(line_id: str, first: bool): + response = SpeedRestrictions.query( + KeyConditionExpression=Key("lineId").eq(line_id), + ScanIndexForward=first, + Limit=1, + ) + loaded = ddb_json.loads(response["Items"]) + return loaded[0]["date"] + + +def query_speed_restrictions(line_id: str, on_date: str): + first_sr_date = get_boundary_date(line_id=line_id, first=True) + latest_sr_date = get_boundary_date(line_id=line_id, first=False) + if on_date < first_sr_date: + return {"available": False} + if on_date > latest_sr_date: + on_date = latest_sr_date + line_condition = Key("lineId").eq(line_id) + date_condition = Key("date").eq(on_date) + condition = line_condition & date_condition + response = SpeedRestrictions.query(KeyConditionExpression=condition, Limit=1) + response_item = ddb_json.loads(response["Items"])[0] + zones = response_item.get("zones", {}).get("zones", {}) + return { + "available": True, + "date": on_date, + "zones": zones, + }