diff --git a/.vscode/settings.json b/.vscode/settings.json index 6528005e..bda1a8d3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,6 @@ }, "[scss]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "inlineChat.mode": "preview" } \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 00000000..c9e8c8a4 --- /dev/null +++ b/app.json @@ -0,0 +1,3 @@ +{ + "expo": {} +} \ No newline at end of file diff --git a/backend/alembic/versions/3095ad90c23e_create_ridership_analytics_table.py b/backend/alembic/versions/3095ad90c23e_create_ridership_analytics_table.py new file mode 100644 index 00000000..d3a53118 --- /dev/null +++ b/backend/alembic/versions/3095ad90c23e_create_ridership_analytics_table.py @@ -0,0 +1,78 @@ +"""create ridership analytics table + +Revision ID: 3095ad90c23e +Revises: 3282eafd6bb4 +Create Date: 2024-03-21 17:37:54.313073 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3095ad90c23e" +down_revision: Union[str, None] = "3282eafd6bb4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + DROP TABLE IF EXISTS public.ridership; + """ + ) + op.create_table( + "ridership_analytics", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "session_id", + sa.Integer, + sa.ForeignKey("van_tracker_session.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "route_id", + sa.Integer, + sa.ForeignKey("routes.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("entered", sa.Integer, nullable=False), + sa.Column("exited", sa.Integer, nullable=False), + sa.Column("lat", sa.Float, nullable=False), + sa.Column("lon", sa.Float, nullable=False), + sa.Column("datetime", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("session_id", "datetime"), + ) + + +def downgrade() -> None: + op.create_table( + "ridership", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "van_id", + sa.Integer, + sa.ForeignKey("vans.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "route_id", + sa.Integer, + sa.ForeignKey("routes.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("entered", sa.Integer, nullable=False), + sa.Column("exited", sa.Integer, nullable=False), + sa.Column("lat", sa.Float, nullable=False), + sa.Column("lon", sa.Float, nullable=False), + sa.Column("datetime", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("van_id", "datetime"), + ) + op.execute( + """ + DROP TABLE IF EXISTS public.ridership_analytics; + """ + ) diff --git a/backend/alembic/versions/3282eafd6bb4_create_van_location_table.py b/backend/alembic/versions/3282eafd6bb4_create_van_location_table.py new file mode 100644 index 00000000..4e18a7f4 --- /dev/null +++ b/backend/alembic/versions/3282eafd6bb4_create_van_location_table.py @@ -0,0 +1,43 @@ +"""create van location table + +Revision ID: 3282eafd6bb4 +Revises: 43451376c0bf +Create Date: 2024-03-11 14:23:32.098485 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3282eafd6bb4" +down_revision: Union[str, None] = "43451376c0bf" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "van_location", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), # pylint: disable=all + ), + sa.Column("session_id", sa.Integer, nullable=False), + sa.Column("lat", sa.Float, nullable=False), + sa.Column("lon", sa.Float, nullable=False), + sa.ForeignKeyConstraint(["session_id"], ["van_tracker_session.id"]), + ) + + +def downgrade() -> None: + op.execute( + """ + DROP TABLE van_location CASCADE; + """ + ) diff --git a/backend/alembic/versions/4183de971218_add_route_description.py b/backend/alembic/versions/4183de971218_add_route_description.py new file mode 100644 index 00000000..d8b6e807 --- /dev/null +++ b/backend/alembic/versions/4183de971218_add_route_description.py @@ -0,0 +1,29 @@ +"""add route description + +Revision ID: 4183de971218 +Revises: 8166e12f260c +Create Date: 2024-03-24 12:31:02.141874 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "4183de971218" +down_revision: Union[str, None] = "8b773adbb487" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "routes", + sa.Column("description", sa.String(255), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_column("routes", "description") diff --git a/backend/alembic/versions/43451376c0bf_create_van_tracker_session_table.py b/backend/alembic/versions/43451376c0bf_create_van_tracker_session_table.py new file mode 100644 index 00000000..efc0420b --- /dev/null +++ b/backend/alembic/versions/43451376c0bf_create_van_tracker_session_table.py @@ -0,0 +1,50 @@ +"""create van tracker session table + +Revision ID: 43451376c0bf +Revises: 8166e12f260c +Create Date: 2024-03-11 14:07:02.375363 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "43451376c0bf" +down_revision: Union[str, None] = "8166e12f260c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "van_tracker_session", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), # pylint: disable=all + ), + sa.Column( + "updated_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ), + sa.Column("van_guid", sa.String, nullable=False), + sa.Column("route_id", sa.Integer, nullable=False), + sa.Column("stop_index", sa.Integer, nullable=False, server_default="-1"), + sa.Column("dead", sa.Boolean, nullable=False, default=False), + ) + + +def downgrade() -> None: + op.execute( + """ + DROP TABLE van_tracker_session CASCADE; + """ + ) diff --git a/backend/alembic/versions/8b773adbb487_drop_van_table.py b/backend/alembic/versions/8b773adbb487_drop_van_table.py new file mode 100644 index 00000000..23591ed7 --- /dev/null +++ b/backend/alembic/versions/8b773adbb487_drop_van_table.py @@ -0,0 +1,35 @@ +"""drop van table + +Revision ID: 8b773adbb487 +Revises: 3095ad90c23e +Create Date: 2024-03-21 17:52:31.297787 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8b773adbb487" +down_revision: Union[str, None] = "3095ad90c23e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + DROP TABLE IF EXISTS public.vans CASCADE; + """ + ) + + +def downgrade() -> None: + op.create_table( + "vans", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("route_id", sa.Integer, sa.ForeignKey("routes.id"), nullable=True), + sa.Column("guid", sa.String(15), nullable=False), + ) diff --git a/backend/src/handlers/ridership.py b/backend/src/handlers/analytics.py similarity index 73% rename from backend/src/handlers/ridership.py rename to backend/src/handlers/analytics.py index e8320153..ee5e04ca 100644 --- a/backend/src/handlers/ridership.py +++ b/backend/src/handlers/analytics.py @@ -11,17 +11,17 @@ from pydantic import BaseModel, model_validator from sqlalchemy.sql import ColumnElement from src.hardware import HardwareErrorCode, HardwareHTTPException, HardwareOKResponse -from src.model.analytics import Analytics -from src.model.van import Van +from src.model.ridership_analytics import RidershipAnalytics +from src.model.van_tracker_session import VanTrackerSession -router = APIRouter(prefix="/analytics/ridership", tags=["analytics", "ridership"]) +router = APIRouter(prefix="/analytics", tags=["analytics", "ridership"]) class RidershipFilterModel(BaseModel): start_timestamp: Optional[int] = None end_timestamp: Optional[int] = None route_id: Optional[int] - van_id: Optional[int] + session_id: Optional[int] @model_validator(mode="after") def check_dates(self): @@ -46,20 +46,49 @@ def end_date(self) -> Optional[datetime]: def filters(self) -> Optional[List[ColumnElement[bool]]]: t_filters = [] if self.start_date is not None: - t_filters.append(Analytics.datetime >= self.start_date) + t_filters.append(RidershipAnalytics.datetime >= self.start_date) if self.end_date is not None: - t_filters.append(Analytics.datetime <= self.end_date) + t_filters.append(RidershipAnalytics.datetime <= self.end_date) if self.route_id is not None: - t_filters.append(Analytics.route_id == self.route_id) - if self.van_id is not None: - t_filters.append(Analytics.van_id == self.van_id) + t_filters.append(RidershipAnalytics.route_id == self.route_id) + if self.session_id is not None: + t_filters.append(RidershipAnalytics.session_id == self.session_id) if len(t_filters) == 0: return None return t_filters -@router.post("/{van_id}") -async def post_ridership_stats(req: Request, van_id: int): +@router.get("/ridership/") +def get_ridership( + req: Request, filters: Optional[RidershipFilterModel] +) -> List[Dict[str, Union[str, int, float]]]: + with req.app.state.db.session() as session: + analytics: List[RidershipAnalytics] = [] + if filters is None or filters.filters is None: + analytics = session.query(RidershipAnalytics).all() + else: + analytics = session.query(RidershipAnalytics).filter(*filters.filters).all() + + # convert analytics to json + analytics_json: List[Dict[str, Union[str, int, float]]] = [] + for analytic in analytics: + analytics_json.append( + { + "sessionId": analytic.session_id, + "routeId": analytic.route_id, + "entered": analytic.entered, + "exited": analytic.exited, + "lat": analytic.lat, + "lon": analytic.lon, + "datetime": int(analytic.datetime.timestamp()), + } + ) + + return analytics_json + + +@router.post("/ridership/{van_guid}") +async def post_ridership_stats(req: Request, van_guid: int): """ ## Upload ridership stats
This route is used by the hardware components to send ridership statistics to be @@ -78,18 +107,18 @@ async def post_ridership_stats(req: Request, van_id: int): timestamp_ms, entered, exited, lat, lon = struct.unpack(" timedelta(minutes=1): + if now - timestamp > timedelta(minutes=1): raise HardwareHTTPException( status_code=400, error_code=HardwareErrorCode.TIMESTAMP_TOO_FAR_IN_PAST ) # Check that the timestamp is not in the future. This implies a hardware clock # malfunction. - if timestamp > current_time: + if timestamp > now: raise HardwareHTTPException( status_code=400, error_code=HardwareErrorCode.TIMESTAMP_IN_FUTURE ) @@ -97,8 +126,12 @@ async def post_ridership_stats(req: Request, van_id: int): with req.app.state.db.session() as session: # Find the route that the van is currently on, required by the ridership database. # If there is no route, then the van does not exist or is not running. - van = session.query(Van).filter_by(id=van_id).first() - if not van: + tracker_session = session.query(VanTrackerSession).filter( + VanTrackerSession.van_guid == van_guid, + VanTrackerSession.dead == False, + now - VanTrackerSession.created_at < timedelta(hours=12), + ) + if not tracker_session: raise HardwareHTTPException( status_code=404, error_code=HardwareErrorCode.VAN_NOT_ACTIVE ) @@ -106,9 +139,9 @@ async def post_ridership_stats(req: Request, van_id: int): # Check that the timestamp is the most recent one for the van. This prevents # updates from being sent out of order, which could mess up the statistics. most_recent = ( - session.query(Analytics) - .filter_by(van_id=van_id) - .order_by(Analytics.datetime.desc()) + session.query(RidershipAnalytics) + .filter_by(session_id=tracker_session.id) + .order_by(RidershipAnalytics.datetime.desc()) .first() ) if most_recent is not None and timestamp <= most_recent.datetime: @@ -117,9 +150,9 @@ async def post_ridership_stats(req: Request, van_id: int): ) # Finally commit the ridership statistics to the database. - new_ridership = Analytics( - van_id=van_id, - route_id=van.route_id, + new_ridership = RidershipAnalytics( + session_id=tracker_session.id, + route_id=tracker_session.route_id, entered=entered, exited=exited, lat=lat, @@ -130,52 +163,3 @@ async def post_ridership_stats(req: Request, van_id: int): session.commit() return HardwareOKResponse() - - -@router.get("/") -def get_ridership( - req: Request, filters: Optional[RidershipFilterModel] -) -> List[Dict[str, Union[str, int, float]]]: - """ - ## Get all ridership analytics. - - **:param filters:** Optional filters for the analytics. Filters include: - - - start_timestamp (int): start timestamp - - end_timestamp (int): end timestamp - - route_id (int): route ID - - van_id (int): van ID - - **:return:** JSON of ridership statistics, including: - - - vanId - - routeId - - entered (number of riders entered) - - existed (number of riders exited) - - lat (latitude) - - lon (longitude) - - datetime - """ - with req.app.state.db.session() as session: - analytics: List[Analytics] = [] - if filters is None or filters.filters is None: - analytics = session.query(Analytics).all() - else: - analytics = session.query(Analytics).filter(*filters.filters).all() - - # convert analytics to json - analytics_json: List[Dict[str, Union[str, int, float]]] = [] - for analytic in analytics: - analytics_json.append( - { - "vanId": analytic.van_id, - "routeId": analytic.route_id, - "entered": analytic.entered, - "exited": analytic.exited, - "lat": analytic.lat, - "lon": analytic.lon, - "datetime": int(analytic.datetime.timestamp()), - } - ) - - return analytics_json diff --git a/backend/src/handlers/routes.py b/backend/src/handlers/routes.py index 6cf008c5..74cc2f5e 100644 --- a/backend/src/handlers/routes.py +++ b/backend/src/handlers/routes.py @@ -23,6 +23,8 @@ from src.model.route_disable import RouteDisable from src.model.route_stop import RouteStop from src.model.stop import Stop +from src.model.stop_disable import StopDisable +from src.model.van_tracker_session import VanTrackerSession from src.model.waypoint import Waypoint from src.request import process_include @@ -34,11 +36,10 @@ FIELD_IS_ACTIVE = "isActive" FIELD_LATITUDE = "latitude" FIELD_LONGITUDE = "longitude" -INCLUDES = { - FIELD_STOP_IDS, - FIELD_WAYPOINTS, - FIELD_IS_ACTIVE, -} +FIELD_DESCRIPTION = "description" +FIELD_STOPS = "stops" +FIELD_COLOR = "color" +INCLUDES = {FIELD_STOP_IDS, FIELD_WAYPOINTS, FIELD_IS_ACTIVE, FIELD_STOPS} router = APIRouter(prefix="/routes", tags=["routes"]) @@ -75,7 +76,12 @@ def get_routes( routes_json = [] for route in routes: - route_json = {FIELD_ID: route.id, FIELD_NAME: route.name} + route_json = { + FIELD_ID: route.id, + FIELD_NAME: route.name, + FIELD_DESCRIPTION: route.description, + FIELD_COLOR: route.color, + } # Add related values to the route if included if FIELD_STOP_IDS in include_set: @@ -87,6 +93,9 @@ def get_routes( if FIELD_IS_ACTIVE in include_set: route_json[FIELD_IS_ACTIVE] = is_route_active(route.id, alert, session) + if FIELD_STOPS in include_set: + route_json[FIELD_STOPS] = query_route_stops(route.id, alert, session) + routes_json.append(route_json) return routes_json @@ -232,7 +241,12 @@ def get_route( if not route: raise HTTPException(status_code=404, detail="Route not found") - route_json = {FIELD_ID: route.id, FIELD_NAME: route.name} + route_json = { + FIELD_ID: route.id, + FIELD_NAME: route.name, + FIELD_DESCRIPTION: route.description, + FIELD_COLOR: route.color, + } # Add related values to the route if included if FIELD_STOP_IDS in include_set: @@ -245,9 +259,60 @@ def get_route( alert = get_current_alert(datetime.now(timezone.utc), session) route_json[FIELD_IS_ACTIVE] = is_route_active(route.id, alert, session) + if FIELD_STOPS in include_set: + route_json[FIELD_STOPS] = query_route_stops(route.id, alert, session) + return route_json +def query_route_stops(route_id: int, alert: Optional[Alert], session): + """ + Queries and returns the stops for the given route ID. + """ + + stops = ( + session.query(Stop) + .order_by(RouteStop.position) + .filter(Stop.id == RouteStop.stop_id) + .filter(RouteStop.route_id == route_id) + .all() + ) + return [ + { + FIELD_ID: stop.id, + FIELD_NAME: stop.name, + FIELD_LATITUDE: stop.lat, + FIELD_LONGITUDE: stop.lon, + FIELD_IS_ACTIVE: is_stop_active(stop, alert, session), + } + for stop in stops + ] + + +def is_stop_active(stop: Stop, alert: Optional[Alert], session) -> bool: + """ + Queries and returns whether the given stop is currently active, i.e it's marked as + active in the database and there is no alert that is disabling it. + """ + + if not alert: + # No alert, fall back to if the current stop is marked as active. + return stop.active + + # If the stop is disabled by the current alert, then it is not active. + enabled = ( + session.query(StopDisable) + .filter( + StopDisable.alert_id == alert.id, + StopDisable.stop_id == stop.id, + ) + .count() + ) == 0 + + # Might still be disabled even if the current alert does not disable the stop. + return stop.active and enabled + + def query_route_stop_ids(route_id: int, session): """ Queries and returns the stop IDs for the given route ID. @@ -375,7 +440,20 @@ async def create_route(req: Request, kml_file: UploadFile): if isinstance(style, PolyStyle): color = style.color break - route_model = Route(name=route_name, color=color) + # Want the text contents of all of the surface-level divs and then strip + # all of the tags of it's content + + route_desc_html = BeautifulSoup(route.description, features="html.parser") + entries = [ + div.text.strip() + for div in route_desc_html.find_all("div", recursive=False) + ] + + if len(entries) < 3: + return HTTPException(status_code=400, detail="bad kml file") + + description = entries[0] + route_model = Route(name=route_name, color=color, description=description) session.add(route_model) session.flush() @@ -394,17 +472,7 @@ async def create_route(req: Request, kml_file: UploadFile): session.add(waypoint) session.flush() - route_desc_html = BeautifulSoup(route.description, features="html.parser") - - # Want the text contents of all of the surface-level divs and then strip - # all of the tags of it's content - - route_stops = [ - div.text.strip() - for div in route_desc_html.find_all("div", recursive=False) - ] - - for pos, stop in enumerate(route_stops): + for pos, stop in enumerate(entries[1:]): if stop not in stop_id_map: continue stop_id = stop_id_map[stop] @@ -425,6 +493,11 @@ async def create_route(req: Request, kml_file: UploadFile): await kml_file.close() + tracker_sessions = session.query(VanTrackerSession).all() + for tracker_session in tracker_sessions: + tracker_session.dead = True + session.commit() + session.commit() return JSONResponse(status_code=200, content={"message": "OK"}) diff --git a/backend/src/handlers/stops.py b/backend/src/handlers/stops.py index b3cfe5bf..149cfdeb 100644 --- a/backend/src/handlers/stops.py +++ b/backend/src/handlers/stops.py @@ -8,6 +8,8 @@ from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel from src.model.alert import Alert +from src.model.route import Route +from src.model.route_disable import RouteDisable from src.model.route_stop import RouteStop from src.model.stop import Stop from src.model.stop_disable import StopDisable @@ -20,10 +22,10 @@ FIELD_LONGITUDE = "longitude" FIELD_ROUTE_IDS = "routeIds" FIELD_IS_ACTIVE = "isActive" -INCLUDES = { - FIELD_ROUTE_IDS, - FIELD_IS_ACTIVE, -} +FIELD_COLORS = "colors" +FIELD_COLOR = "color" +FIELD_ROUTES = "routes" +INCLUDES = {FIELD_ROUTE_IDS, FIELD_IS_ACTIVE, FIELD_COLORS, FIELD_ROUTES} router = APIRouter(prefix="/stops", tags=["stops"]) @@ -76,6 +78,12 @@ def get_stops( if FIELD_IS_ACTIVE in include_set: stop_json[FIELD_IS_ACTIVE] = is_stop_active(stop, alert, session) + if FIELD_COLORS in include_set: + stop_json[FIELD_COLORS] = query_stop_colors(stop.id, session) + + if FIELD_ROUTES in include_set: + stop_json[FIELD_ROUTES] = query_routes(stop.id, alert, session) + stops_json.append(stop_json) return stops_json @@ -126,9 +134,77 @@ def get_stop( if FIELD_ROUTE_IDS in include_set: stop_json[FIELD_ROUTE_IDS] = query_route_ids(stop.id, session) + if FIELD_COLORS in include_set: + stop_json[FIELD_COLORS] = query_stop_colors(stop.id, session) + + if FIELD_ROUTES in include_set: + stop_json[FIELD_ROUTES] = query_routes(stop.id, alert, session) + return stop_json +def query_stop_colors(stop_id: int, session) -> list[str]: + """ + Queries and returns the color of the stop. + """ + + return [ + color + for (color,) in session.query(Route) + .join(RouteStop) + .filter(RouteStop.stop_id == stop_id) + .with_entities(Route.color) + .all() + ] + + +def query_routes( + stop_id: int, alert: Optional[Alert], session +) -> list[dict[str, str | bool | int]]: + """ + Queries and returns the routes that the given stop is assigned to. + """ + + return [ + { + FIELD_ID: route.id, + FIELD_NAME: route.name, + FIELD_IS_ACTIVE: is_route_active(route.id, alert, session), + FIELD_COLOR: route.color, + } + for route in ( + session.query(Route) + .join(RouteStop) + .filter(RouteStop.stop_id == stop_id) + .order_by(Route.id) + .all() + ) + ] + + +def is_route_active(route_id: int, alert: Optional[Alert], session) -> bool: + """ + Queries and returns whether the frontend is currently active, i.e + not disabled by the current alert. + """ + + if not alert: + # No alert, should be active + return True + + # If the route is disabled by the current alert, then it is not active. + enabled = ( + session.query(RouteDisable) + .filter( + RouteDisable.alert_id == alert.id, + RouteDisable.route_id == route_id, + ) + .count() + ) == 0 + + return enabled + + def query_route_ids(stop_id: int, session) -> list[int]: """ Queries and returns the route ids that the given stop is assigned to. diff --git a/backend/src/handlers/vans.py b/backend/src/handlers/vans.py index a484dd89..c4b5bb37 100644 --- a/backend/src/handlers/vans.py +++ b/backend/src/handlers/vans.py @@ -1,344 +1,590 @@ import asyncio -import json import struct from datetime import datetime, timedelta, timezone -from typing import Dict, List, Optional, Set, Union - -from fastapi import APIRouter, HTTPException, Query, Request, WebSocket -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse, StreamingResponse +from math import cos, radians, sqrt +from typing import Annotated, Any, Dict, List, Optional, Union + +from fastapi import ( + APIRouter, + HTTPException, + Query, + Request, + WebSocket, + WebSocketDisconnect, +) from pydantic import BaseModel +from sqlalchemy import func from src.hardware import HardwareErrorCode, HardwareHTTPException, HardwareOKResponse from src.model.route import Route from src.model.route_stop import RouteStop from src.model.stop import Stop -from src.model.van import Van +from src.model.van_location import VanLocation +from src.model.van_tracker_session import VanTrackerSession from src.request import process_include -from src.vantracking.coordinate import Coordinate -from src.vantracking.location import Location -from starlette.responses import Response - - -class VanModel(BaseModel): - """ - A model for the request body to make a new van or update a van - """ - - route_id: int - guid: str - - -class VanLocation(BaseModel): - """ - A model for the request body to make a new van or update a van - """ - - timestamp: datetime - latitude: float - longitude: float - router = APIRouter(prefix="/vans", tags=["vans"]) -INCLUDE_LOCATION = "location" -INCLUDES: Set[str] = { - INCLUDE_LOCATION, -} +FIELD_ID = "id" +FIELD_GUID = "guid" +FIELD_LATITUDE = "latitude" +FIELD_LONGITUDE = "longitude" +FIELD_COLOR = "color" +FIELD_ROUTE_ID = "routeId" +FIELD_ROUTE_IDS = "routeIds" +FIELD_ALIVE = "alive" +FIELD_LOCATION = "location" +FIELD_CREATED_AT = "started" +FIELD_UPDATED_AT = "updated" +FIELD_TYPE = "type" +FIELD_ARRIVALS = "arrivals" +FIELD_VAN = "van" +FIELD_VANS = "vans" +TYPE_ERROR = "error" +INCLUDES_V1 = {FIELD_LOCATION} +INCLUDES_V2 = {FIELD_COLOR, FIELD_LOCATION} + +THRESHOLD_RADIUS_M = 30.48 # 100 ft +THRESHOLD_TIME = timedelta(seconds=10) +AVERAGE_VAN_SPEED_MPS = 8.9408 # 20 mph + +KM_LAT_RATIO = 111.32 # km/degree latitude +EARTH_CIRCUFERENCE_KM = 40075 # km +DEGREES_IN_CIRCLE = 360 # degrees @router.get("/") -def get_vans( - req: Request, include: Union[List[str], None] = Query(default=None) -) -> JSONResponse: - """ - ## Get all vans. - - **:param include:** Optional list of fields to include. Valid values are: - - - "location": includes the current location of the van - - **:return:** A list of vans in the format - - - id - - routeId - - guid - """ - include_set = process_include(include=include, allowed=INCLUDES) - with req.app.state.db.session() as session: - vans: List[Van] = session.query(Van).order_by(Van.id).all() - - last_id = vans[0].id if vans else None - resp: List[Dict[str, Optional[Union[int, float, str]]]] = [] - for van in vans: - if last_id is not None: - gap = van.id - last_id - for i in range(1, gap): - resp.append( - {"id": last_id + i, "routeId": van.route_id, "guid": "16161616"} - ) - resp.append( - { - "id": van.id, - "routeId": van.route_id, - "guid": van.guid, - } - ) - last_id = van.id - - return JSONResponse(content=resp) - - -@router.get("/{van_id}") -def get_van( - req: Request, van_id: int, include: Union[List[str], None] = Query(default=None) -) -> JSONResponse: - """ - ## Get a van by ID. - - **:param van_id:** The unique integer ID of the van to retrieve - - **:param include:** Optional list of fields to include. Valid values are: - - - "location": includes the current location of the van - - **:return:** A van in the format - - - id - - routeId - """ - include_set = process_include(include=include, allowed=INCLUDES) +async def get_van_v1( + req: Request, + include: Annotated[List[str] | None, Query()] = None, +) -> List[Dict[str, Union[int, str]]]: + include_set = process_include(include, INCLUDES_V1) with req.app.state.db.session() as session: - van: Van = session.query(Van).filter_by(id=van_id).first() - if van is None: - return JSONResponse(content={"message": "Van not found"}, status_code=404) - - resp = { - "id": van_id, - "routeId": van.route_id, - } - - return JSONResponse(content=resp) - - -@router.get("/location/", response_class=Response) -def get_locations(req: Request): - """ - ## Get all van locations. - - **:return:** JSON of all van locations, including: - - - timestamp - - latitude - - longitude - - nextStopId - - secondsToNextStop - """ - - vans = get_all_van_ids(req) - return JSONResponse(content=get_location_for_vans(req, vans)) + tracker_sessions = ( + session.query(VanTrackerSession) + .order_by(VanTrackerSession.van_guid, VanTrackerSession.created_at.desc()) + .distinct(VanTrackerSession.van_guid) + .all() + ) + locations_json: List[Dict[str, Union[int, str]]] = [] + for tracker_session in tracker_sessions: + van_json = { + FIELD_ID: int(tracker_session.van_guid), + FIELD_ROUTE_ID: tracker_session.route_id, + FIELD_GUID: tracker_session.van_guid, + } + if FIELD_LOCATION in include_set: + van_json[FIELD_LOCATION] = query_most_recent_location( + session, tracker_session + ) + locations_json.append(van_json) + return locations_json @router.websocket("/location/subscribe/") -async def subscribe_locations(websocket: WebSocket) -> None: - """ - ## Subscribe to all van locations. - """ +async def subscribe_location_v1(websocket: WebSocket): await websocket.accept() - vans = get_all_van_ids(websocket) while True: - locations_json = get_location_for_vans(websocket, vans) - await websocket.send_json(locations_json) - await asyncio.sleep(2) - - -@router.get("/location/{van_id}") -def get_location(req: Request, van_id: int) -> JSONResponse: - """ - ## Get the location of a van by ID. - - **:param van_id:** The unique integer ID of the van to retrieve + now = datetime.now(timezone.utc) + with websocket.app.state.db.session() as session: + tracker_sessions = ( + session.query(VanTrackerSession) + .filter( + VanTrackerSession.dead == False, + not_stale(now, VanTrackerSession.created_at), + ) + .order_by( + VanTrackerSession.van_guid, VanTrackerSession.created_at.desc() + ) + .distinct(VanTrackerSession.van_guid) + .all() + ) + locations_json: Dict[int, Dict[str, Union[str, int, float]]] = {} + for tracker_session in tracker_sessions: + location = query_most_recent_location(session, tracker_session) + stops = ( + session.query(Stop) + .join(RouteStop) + .filter(RouteStop.route_id == tracker_session.route_id) + .order_by(RouteStop.position) + .all() + ) + next_stop_index = (tracker_session.stop_index + 1) % len(stops) + stop = stops[next_stop_index] + distance_m = distance_meters( + stop.lat, stop.lon, location.lat, location.lon + ) + seconds_to_next_stop = distance_m / AVERAGE_VAN_SPEED_MPS + location_json: Dict[str, Union[str, int, float]] = { + "timestamp": int(location.created_at.timestamp()), + "latitude": location.lat, + "longitude": location.lon, + "nextStopId": stop.id, + "secondsToNextStop": seconds_to_next_stop, + } + locations_json[tracker_session.van_guid] = location_json + await websocket.send_json(locations_json) + await asyncio.sleep(2) + + +@router.get("/v2") +async def get_vans_v2( + req: Request, + alive: Optional[bool] = None, + route_ids: Annotated[List[int] | None, Query()] = None, + include: Annotated[List[str] | None, Query()] = None, +) -> List[Dict[str, Union[bool, float, str, int, Dict[str, float]]]]: + include_set = process_include(include, INCLUDES_V2) + now = datetime.now(timezone.utc) + with req.app.state.db.session() as session: + result = query_latest_vans(session, now, alive, route_ids, include_set) + return result + + +@router.get("/v2/{van_guid}") +async def get_van_v2( + req: Request, + van_guid: str, + include: Annotated[List[str] | None, Query()] = None, +) -> Dict[str, Union[bool, float, str, int, Dict[str, float]]]: + include_set = process_include(include, INCLUDES_V2) + now = datetime.now(timezone.utc) + with req.app.state.db.session() as session: + return query_latest_van(session, now, van_guid, include_set) - **:return:** JSON of the van location, including: - - timestamp - - latitude - - longitude - - nextStopId - - secondsToNextStop - """ - if van_id not in req.app.state.van_locations: - raise HTTPException(detail="Van not found", status_code=404) +class VanSubscriptionQueryModel(BaseModel): + type: str + guid: Optional[str] = None + alive: Optional[bool] = None + routeIds: Optional[List[int]] = None - location_json = get_location_for_van(req, van_id) - return JSONResponse(content=location_json) +class VanSubscriptionMessageModel(BaseModel): + include: List[str] + query: VanSubscriptionQueryModel -@router.websocket("/location/{van_id}/subscribe") -async def subscribe_location(websocket: WebSocket, van_id: int) -> None: - if van_id not in websocket.app.state.van_locations: - raise HTTPException(detail="Van not found", status_code=404) +@router.websocket("/v2/subscribe/") +async def subscribe_vans(websocket: WebSocket) -> None: await websocket.accept() - while True: - location_json = get_location_for_van(websocket, van_id) - await websocket.send_json(location_json) - await asyncio.sleep(2) - - -def get_all_van_ids(req: Union[Request, WebSocket]) -> List[int]: - with req.app.state.db.session() as session: - return [van_id for (van_id,) in session.query(Van).with_entities(Van.id).all()] - - -def get_location_for_vans( - req: Union[Request, WebSocket], van_ids: List[int] -) -> Dict[int, dict[str, Union[str, int]]]: - locations_json: Dict[int, dict[str, Union[str, int]]] = {} - for van_id in van_ids: - state = req.app.state.van_tracker.get_van(van_id) - if state is None: + try: + # Given the dynamic nature of subscribing, we actually overload the message + # sent such that you can specify a vanguid or a route filter rather than + # having 2 separate http routes. + msg_json = await websocket.receive_json() + except WebSocketDisconnect: + break + try: + msg = VanSubscriptionMessageModel(**msg_json) + include_set = process_include(msg.include, INCLUDES_V2) + now = datetime.now(timezone.utc) + with websocket.app.state.db.session() as session: + resp: Dict[ + str, + Union[ + str, + Dict[str, Union[float, str, bool, int, dict[str, float]]], + List[Dict[str, Union[float, str, bool, int, dict[str, float]]]], + ], + ] = { + FIELD_TYPE: msg.query.type, + } + if msg.query.type == FIELD_VAN: + if msg.query.guid is None: + raise HTTPException( + status_code=400, detail="GUID must be specified" + ) + resp[FIELD_VAN] = query_latest_van( + session, now, msg.query.guid, include_set + ) + elif msg.query.type == FIELD_VANS: + resp[FIELD_VANS] = query_latest_vans( + session, + now, + msg.query.alive, + msg.query.routeIds, + include_set, + ) + else: + raise HTTPException( + status_code=400, + detail="Invalid filter " + msg.query.type + " specified", + ) + await websocket.send_json(resp) + except HTTPException as e: + resp = { + FIELD_TYPE: TYPE_ERROR, + TYPE_ERROR: e.detail, + } + await websocket.send_json(resp) continue - locations_json[van_id] = { - "timestamp": int(state.location.timestamp.timestamp()), - "latitude": state.location.coordinate.latitude, - "longitude": state.location.coordinate.longitude, - "nextStopId": state.next_stop.id, - "secondsToNextStop": int(state.seconds_to_next_stop.total_seconds()), - } - + except Exception as e: + await websocket.close() + raise e + await websocket.close() + + +def query_latest_van( + session, now: datetime, guid: str, include_set: set[str] +) -> Dict[str, Union[float, str, bool, int, Dict[str, float]]]: + tracker_session = ( + session.query(VanTrackerSession) + .filter(VanTrackerSession.van_guid == guid) + .first() + ) + if tracker_session is None: + raise HTTPException(status_code=404, detail="Van not found") + return base_query_van(session, now, tracker_session, include_set) + + +def query_latest_vans( + session, + now: datetime, + alive: Optional[bool], + route_ids: Optional[List[int]], + include_set: set[str], +) -> List[Dict[str, Union[float, str, bool, int, Dict[str, float]]]]: + tracker_query = ( + session.query(VanTrackerSession) + .order_by(VanTrackerSession.van_guid, VanTrackerSession.created_at.desc()) + .distinct(VanTrackerSession.van_guid) + ) + + tracker_sessions = tracker_query.all() + + if alive is not None: + tracker_sessions = [ + tracker_session + for tracker_session in tracker_sessions + if not tracker_session.dead == alive + and not_stale(now, tracker_session.created_at) + ] + + if route_ids is not None: + tracker_sessions = [ + tracker_session + for tracker_session in tracker_sessions + if tracker_session.route_id in route_ids + ] + + locations_json: List[Dict[str, Union[float, str, bool, int, Dict[str, float]]]] = [] + for tracker_session in tracker_sessions: + locations_json.append( + base_query_van(session, now, tracker_session, include_set) + ) return locations_json -def get_location_for_van( - req: Union[Request, WebSocket], van_id: int -) -> dict[str, Union[str, int]]: - state = req.app.state.van_tracker.get_van(van_id) - if state is None: - return {} - return { - "timestamp": int(state.location.timestamp.timestamp()), - "latitude": state.location.coordinate.latitude, - "longitude": state.location.coordinate.longitude, - "nextStopId": state.next_stop.id, - "secondsToNextStop": int(state.seconds_to_next_stop.total_seconds()), +def base_query_van( + session, now: datetime, tracker_session: VanTrackerSession, include_set: set[str] +) -> Dict[str, Union[float, str, bool, int, Dict[str, float]]]: + van_json: Dict[str, Union[float, str, bool, int, Dict[str, float]]] = { + FIELD_GUID: str(tracker_session.van_guid), + FIELD_ALIVE: not tracker_session.dead + and not_stale(now, tracker_session.created_at), + FIELD_CREATED_AT: int(tracker_session.created_at.timestamp()), + FIELD_UPDATED_AT: int(tracker_session.updated_at.timestamp()), } + if FIELD_LOCATION in include_set: + location = query_most_recent_location(session, tracker_session) + if location is not None: + van_json[FIELD_LOCATION] = { + FIELD_LATITUDE: location.lat, + FIELD_LONGITUDE: location.lon, + } + if FIELD_COLOR in include_set: + route = ( + session.query(Route).filter(Route.id == tracker_session.route_id).first() + ) + van_json[FIELD_COLOR] = route.color + return van_json -@router.post("/routeselect/{van_guid}") -async def post_routeselect(req: Request, van_guid: str) -> HardwareOKResponse: - """ - ## Select a route for a van. +@router.websocket("/v2/arrivals/subscribe") +async def subscribe_arrivals(websocket: WebSocket) -> None: + await websocket.accept() + while True: + try: + stop_filter: Dict[str, List[int]] = await websocket.receive_json() + except WebSocketDisconnect: + break + # Make sure stop filter confiorms to expected format + if not all( + isinstance(k, str) and isinstance(v, list) for k, v in stop_filter.items() + ): + await websocket.send_json( + {FIELD_TYPE: TYPE_ERROR, TYPE_ERROR: "Invalid stop filter"} + ) + continue + now = datetime.now(timezone.utc) + with websocket.app.state.db.session() as session: + try: + arrivals = query_arrivals(session, now, stop_filter) + except Exception as e: + await websocket.close() + raise e + response = {FIELD_TYPE: FIELD_ARRIVALS, FIELD_ARRIVALS: arrivals} + await websocket.send_json(response) + await websocket.close() + + +def query_arrivals( + session, now: datetime, stop_filter: Dict[str, List[int]] +) -> Dict[int, Dict[int, int]]: + arrivals_json: Dict[int, Dict[int, int]] = {} + for stop_id_str in stop_filter: + stop_id = int(stop_id_str) + stop_arrivals_json: Dict[int, int] = {} + for route_id in stop_filter[stop_id_str]: + stops = ( + session.query(Stop) + .join(RouteStop) + .filter(RouteStop.route_id == route_id) + .order_by(RouteStop.position) + .all() + ) + if not stops: + continue + stop_index = next( + (index for index, stop in enumerate(stops) if stop.id == stop_id), + None, + ) + if stop_index is None: + continue + distance_m = calculate_van_distance( + session, now, stops, stop_index, route_id + ) + if not distance_m: + continue + stop_arrivals_json[route_id] = distance_m / AVERAGE_VAN_SPEED_MPS + if stop_arrivals_json: + arrivals_json[stop_id] = stop_arrivals_json + return arrivals_json + + +def calculate_van_distance( + session, now: datetime, stops: List[Stop], stop_index: int, route_id: int +): + current_distance = 0.0 + current_stop = stops[stop_index] + start = stop_index + while True: + arriving_session = active_session_query( + session, + now, + VanTrackerSession.route_id == route_id, + VanTrackerSession.stop_index == (stop_index - 1) % len(stops), + ).first() + if arriving_session is not None: + location = query_most_recent_location(session, arriving_session) + if location is not None: + current_distance += distance_meters( + location.lat, + location.lon, + current_stop.lat, + current_stop.lon, + ) + return current_distance + # backtrack by decrementing stop_index, wrapping around if necessary + stop_index = (stop_index - 1) % len(stops) + if stop_index == start: + break + last_stop = current_stop + current_stop = stops[stop_index] + current_distance += distance_meters( + last_stop.lat, + last_stop.lon, + current_stop.lat, + current_stop.lon, + ) - **:param van_guid:** The unique str identifier of the van - **:return:** *Hardware OK* message - """ +@router.post("/routeselect/{van_guid}") # Called routeselect for backwards compat +async def begin_session(req: Request, van_guid: str) -> HardwareOKResponse: body = await req.body() route_id = struct.unpack(" HardwareOKResponse: - """ - ## Update the location of a van. - - **:param van_guid:** The unique str identifier of the van - - **:return:** *Hardware OK* message - """ - with req.app.state.db.session() as session: - van = session.query(Van).filter_by(guid=van_guid).first() - if van is None: - new_van = Van(guid=van_guid) - session.add(new_van) - session.commit() - # byte body: long long for timestamp, double for lat, double for lon body = await req.body() timestamp_ms, lat, lon = struct.unpack(" timedelta(minutes=1): + if now - timestamp > timedelta(minutes=1): raise HardwareHTTPException( status_code=400, error_code=HardwareErrorCode.TIMESTAMP_TOO_FAR_IN_PAST ) # Check that the timestamp is not in the future. This implies a hardware clock # malfunction. - if timestamp > current_time: + if timestamp > now: raise HardwareHTTPException( status_code=400, error_code=HardwareErrorCode.TIMESTAMP_IN_FUTURE ) - # Check that the timestamp is the most recent one for the van. This prevents - # updates from being sent out of order. with req.app.state.db.session() as session: - van = session.query(Van).filter_by(guid=van_guid).first() - van_state = req.app.state.van_tracker.get_van(van.id) - if van_state is not None and timestamp < van_state.location.timestamp: + tracker_session = active_session_query( + session, now, VanTrackerSession.van_guid == van_guid + ).first() + if tracker_session is None: raise HardwareHTTPException( - status_code=400, error_code=HardwareErrorCode.TIMESTAMP_NOT_MOST_RECENT + status_code=400, error_code=HardwareErrorCode.CREATE_NEW_SESSION ) - # The van may be starting up for the first time, in which we need to initialize it's - # cache entry and stop list. It's better to do this once rather than coupling it with - # push_location due to the very expensive stop query we have to do. - if van.id not in req.app.state.van_tracker: - with req.app.state.db.session() as session: - # Need to find the likely list of stops this van will go on. It's assumed that - # this will only change between van activations, so we can query once and then - # cache this list. - stops = ( - session.query(Stop) - .join(RouteStop, Stop.id == RouteStop.stop_id) - # Make sure all stops will be in order since that's critical for the time estimate - .order_by(RouteStop.position) - .join(Van, Van.route_id == RouteStop.route_id) - .filter(Van.guid == van_guid) - # Ignore inactive stops we won't be going to and thus don't need to estimate times for - .filter(Stop.active == True) - .all() + stops = ( + session.query(Stop) # Query Stop instead of RouteStop + .join(RouteStop, RouteStop.stop_id == Stop.id) + .filter(RouteStop.route_id == tracker_session.route_id) + .order_by(RouteStop.position) + .all() + ) + + # Add location to session + new_location = VanLocation( + session_id=tracker_session.id, created_at=timestamp, lat=lat, lon=lon + ) + session.add(new_location) + + if tracker_session.created_at == tracker_session.updated_at: + stop_pairs = [] + for i in range(len(stops) - 1): + left, right = stops[i], stops[i + 1] + l_distance = distance_meters(lat, lon, left.lat, left.lon) + r_distance = distance_meters(lat, lon, right.lat, right.lon) + stop_pairs.append((i, i + 1, l_distance, r_distance)) + closest_stop_pair = min(stop_pairs, key=lambda x: min(x[2], x[3])) + if closest_stop_pair[2] >= closest_stop_pair[3]: + tracker_session.stop_index = closest_stop_pair[1] + else: + tracker_session.stop_index = closest_stop_pair[0] + + tracker_session.updated_at = timestamp + session.commit() + + # To find an accurate time estimate for a stop, we need to remove vans that are not arriving at a stop, either + # because they aren't arriving at the stop or because they have departed it. We achieve this currently by + # implementing a state machine that tracks the (guessed) current stop of each van. We can then use that to find + # the next logical stop and estimate the time to arrive to that. + + # We want to consider all of the stops that are coming up for this van, as that allows to handle cases where a + # stop is erroneously skipped. Also make sure we include subsequent stops that wrap around. The wrap around slice + # needs to be bounded to 0 to prevent a negative index causing weird slicing behavior. + subsequent_stops = ( + stops[tracker_session.stop_index + 1 :] + + stops[: max(tracker_session.stop_index - 1, 0)] + ) + locations = ( + session.query(VanLocation) + .filter( + VanLocation.session_id == tracker_session.id, + VanLocation.created_at > now - timedelta(seconds=300), + ) + .order_by(VanLocation.created_at) + ) + for i, stop in enumerate(subsequent_stops): + longest_subset: List[datetime] = [] + current_subset: List[datetime] = [] + + # Find the longest consequtive subset (i.e streak) where the distance of the past couple of + # van locations is consistently within this stop's radius. + for location in locations: + stop_distance_meters = distance_meters( + location.lat, + location.lon, + stop.lat, + stop.lon, + ) + if stop_distance_meters < THRESHOLD_RADIUS_M: + current_subset.append(location.created_at) + else: + if len(current_subset) > len(longest_subset): + longest_subset = current_subset + current_subset = [] + if len(current_subset) > len(longest_subset): + longest_subset = current_subset + + if longest_subset: + # A streak exists, find the overall duration that this van was within the stop's radius. Since locations + # are ordered from oldest to newest, we can just subtract the first and last timestamps. + duration = longest_subset[-1] - longest_subset[0] + else: + # No streak, so we weren't at the stop for any amount of time. + duration = timedelta(seconds=0) + + if duration >= THRESHOLD_TIME: + # We were at this stop for long enough, move to it. Since the stops iterated through are relative to the + # current stop, we have to add the current stop index to the current index in the loop to get the actual + # stop index. + # Note: It's possible that the van was at another stop's radius for even longer, but this is not considered + # until real-world testing shows this edge case to be important. + tracker_session.stop_index = (tracker_session.stop_index + i + 1) % len( + stops ) + session.commit() + break - if not stops: - # No stops implies a van that does not exist - raise HardwareHTTPException( - status_code=400, error_code=HardwareErrorCode.VAN_DOESNT_EXIST - ) + return HardwareOKResponse() - req.app.state.van_tracker.init_van(van.id, stops) - with req.app.state.db.session() as session: - van = session.query(Van).filter_by(guid=van_guid).first() - if van is None: - raise HardwareHTTPException( - status_code=400, error_code=HardwareErrorCode.VAN_DOESNT_EXIST - ) +def active_session_query(session, now: datetime, *filters): + query = session.query(VanTrackerSession).filter( + VanTrackerSession.dead == False, + not_stale(now, VanTrackerSession.created_at), + *filters + ) + return query - # Update the van's location - req.app.state.van_tracker.push_location( - van.id, - Location( - timestamp=timestamp, coordinate=Coordinate(latitude=lat, longitude=lon) - ), - ) - return HardwareOKResponse() +def not_stale(now: datetime, datetimeish) -> bool: + return now - datetimeish < timedelta(hours=12) + + +def query_most_recent_location( + session, tracker_session: VanTrackerSession +) -> VanLocation: + return ( + session.query(VanLocation) + .filter_by(session_id=tracker_session.id) + .order_by(VanLocation.created_at.desc()) + .first() + ) + + +def distance_meters(alat: float, alon: float, blat: float, blon: float) -> float: + dlat = blat - alat + dlon = blon - alon + + # Simplified distance calculation that assumes the earth is a sphere. This is good enough for our purposes. + # https://stackoverflow.com/a/39540339 + dlatkm = dlat * KM_LAT_RATIO + dlonkm = dlon * EARTH_CIRCUFERENCE_KM * cos(radians(alat)) / DEGREES_IN_CIRCLE + + return sqrt(dlatkm**2 + dlonkm**2) * 1000 diff --git a/backend/src/hardware.py b/backend/src/hardware.py index 599e44bb..999fd14d 100644 --- a/backend/src/hardware.py +++ b/backend/src/hardware.py @@ -42,6 +42,8 @@ class HardwareErrorCode(Enum): VAN_DOESNT_EXIST = 4 TOO_MANY_ROUTES = 5 ROUTE_NAME_TOO_LONG = 6 + CREATE_NEW_SESSION = 7 + INVALID_ROUTE_ID = 8 class HardwareHTTPException(Exception): diff --git a/backend/src/main.py b/backend/src/main.py index 0eb10bc0..92839bb5 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -3,10 +3,9 @@ from fastapi.middleware.cors import CORSMiddleware from .db import DBWrapper -from .handlers import ada, alert, ridership, routes, stops, vans +from .handlers import ada, alert, analytics, routes, stops, vans from .hardware import HardwareExceptionMiddleware -from .vantracking.factory import van_tracker -from .vantracking.tracker import VanTracker +from .model.van_tracker_session import VanTrackerSession load_dotenv() @@ -25,11 +24,15 @@ app.include_router(routes.router) app.include_router(stops.router) app.include_router(alert.router) -app.include_router(ridership.router) +app.include_router(analytics.router) app.include_router(vans.router) @app.on_event("startup") def startup_event(): app.state.db = DBWrapper() - app.state.van_tracker: VanTracker = van_tracker() + with app.state.db.session() as session: + tracker_sessions = session.query(VanTrackerSession).all() + for tracker_session in tracker_sessions: + tracker_session.dead = True + session.commit() diff --git a/backend/src/model/analytics.py b/backend/src/model/ridership_analytics.py similarity index 68% rename from backend/src/model/analytics.py rename to backend/src/model/ridership_analytics.py index 2f0898d9..de4d0f06 100644 --- a/backend/src/model/analytics.py +++ b/backend/src/model/ridership_analytics.py @@ -6,17 +6,17 @@ from src.model.types import TZDateTime -class Analytics(Base): - __tablename__ = "analytics" +class RidershipAnalytics(Base): + __tablename__ = "ridership_analytics" __table_args__ = ( - ForeignKeyConstraint(["van_id"], ["vans.id"]), + ForeignKeyConstraint(["session_id"], ["van_tracker_session.id"]), ForeignKeyConstraint(["route_id"], ["routes.id"]), - UniqueConstraint("van_id", "datetime"), + UniqueConstraint("session_id", "datetime"), ) id: Mapped[int] = mapped_column( primary_key=True, autoincrement=True, nullable=False ) - van_id: Mapped[int] = mapped_column(nullable=False) + session_id: Mapped[int] = mapped_column(nullable=False) route_id: Mapped[int] = mapped_column(nullable=False) entered: Mapped[int] = mapped_column(nullable=False) exited: Mapped[int] = mapped_column(nullable=False) @@ -27,8 +27,8 @@ class Analytics(Base): def __eq__(self, __value: object) -> bool: # Exclude ID since it'll always differ, only compare on content return ( - isinstance(__value, Analytics) - and self.van_id == __value.van_id + isinstance(__value, RidershipAnalytics) + and self.session_id == __value.session_id and self.route_id == __value.route_id and self.entered == __value.entered and self.exited == __value.exited @@ -38,4 +38,4 @@ def __eq__(self, __value: object) -> bool: ) def __repr__(self) -> str: - return f"" + return f"" diff --git a/backend/src/model/route.py b/backend/src/model/route.py index 64937f56..78a2f98e 100644 --- a/backend/src/model/route.py +++ b/backend/src/model/route.py @@ -17,6 +17,11 @@ class Route(Base): String(7), nullable=True, ) + description: Mapped[str] = mapped_column( + String(255), + nullable=False, + server_default="", + ) waypoints = relationship("Waypoint", backref="route", cascade="all, delete-orphan") diff --git a/backend/src/model/van.py b/backend/src/model/van.py deleted file mode 100644 index 2353ac63..00000000 --- a/backend/src/model/van.py +++ /dev/null @@ -1,24 +0,0 @@ -from sqlalchemy import ForeignKeyConstraint -from sqlalchemy.orm import Mapped, mapped_column -from src.db import Base - - -class Van(Base): - __tablename__ = "vans" - __table_args__ = tuple(ForeignKeyConstraint(["route_id"], ["routes.id"])) - id: Mapped[int] = mapped_column( - primary_key=True, autoincrement=True, nullable=False - ) - route_id: Mapped[int] = mapped_column(nullable=True) - guid: Mapped[str] = mapped_column(nullable=False) - - def __eq__(self, __value: object) -> bool: - # Exclude ID since it'll always differ, only compare on content - return ( - isinstance(__value, Van) - and self.route_id == __value.route_id - and self.guid == __value.guid - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/src/model/van_location.py b/backend/src/model/van_location.py new file mode 100644 index 00000000..39d9a49d --- /dev/null +++ b/backend/src/model/van_location.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from sqlalchemy import ForeignKey, ForeignKeyConstraint, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from src.db import Base +from src.model.types import TZDateTime + + +class VanLocation(Base): + __tablename__ = "van_location" + __table_args__ = (ForeignKeyConstraint(["session_id"], ["van_tracker_session.id"]),) + + id: Mapped[int] = mapped_column( + primary_key=True, autoincrement=True, nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + TZDateTime, nullable=False, server_default=func.now() # pylint: disable=all + ) + session_id: Mapped[int] = mapped_column( + ForeignKey("van_tracker_session.id"), nullable=False + ) + lat: Mapped[float] = mapped_column(nullable=False) + lon: Mapped[float] = mapped_column(nullable=False) + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, VanLocation) + and self.created_at == other.created_at + and self.session_id == other.session_id + and self.lat == other.lat + and self.lon == other.lon + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/src/model/van_tracker_session.py b/backend/src/model/van_tracker_session.py new file mode 100644 index 00000000..5289b077 --- /dev/null +++ b/backend/src/model/van_tracker_session.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from src.db import Base +from src.model.types import TZDateTime + + +class VanTrackerSession(Base): + __tablename__ = "van_tracker_session" + + id: Mapped[int] = mapped_column( + primary_key=True, autoincrement=True, nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + TZDateTime, nullable=False, server_default=func.now() # pylint: disable=all + ) + updated_at: Mapped[datetime] = mapped_column( + TZDateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), # pylint: disable=all + ) + van_guid: Mapped[str] = mapped_column(nullable=False) + route_id: Mapped[int] = mapped_column(nullable=False) + stop_index: Mapped[int] = mapped_column(nullable=False, server_default="-1") + dead: Mapped[bool] = mapped_column(nullable=False, default=False) + + def __eq__(self, __value: object) -> bool: + return ( + isinstance(__value, VanTrackerSession) + and self.created_at == __value.created_at + and self.updated_at == __value.updated_at + and self.van_guid == __value.van_guid + and self.route_id == __value.route_id + and self.stop_index == __value.stop_index + and self.dead == __value.dead + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/src/vantracking/__init__.py b/backend/src/vantracking/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/vantracking/cache.py b/backend/src/vantracking/cache.py deleted file mode 100644 index a747ead6..00000000 --- a/backend/src/vantracking/cache.py +++ /dev/null @@ -1,54 +0,0 @@ -from abc import ABC, abstractmethod - -from src.model.stop import Stop -from src.vantracking.location import Location - - -class VanStateCache(ABC): - @abstractmethod - def __contains__(self, van_id: int): - pass - - @abstractmethod - def add(self, van_id: int, stops: list[Stop]): - """ - Add a new van state to the cache tied to the van ID. The stop list will be used for time - estimates, so it should be in the exact order the van will visit each stop. The van state - must be consistently updated or it will be removed from the cache. - """ - - @abstractmethod - def get_locations(self, van_id: int) -> list[Location]: - """ - Get the current location list tied to the van ID (if it exists). The location list will be - ordered from oldest to newest location. Should throw an exception if the van state does not exist. - """ - - @abstractmethod - def push_location(self, van_id: int, location: Location): - """ - Push a new location to the van state tied to the van ID (if it exists). This will extend the - lifespan of the van state in the cache. Should throw an exception if the van state does not exist. - """ - - @abstractmethod - def get_stops(self, van_id: int) -> list[Stop]: - """ - Get the current stop list tied to the van ID (if it exists). Should throw an exception if - the van state does not exist. - """ - - @abstractmethod - def get_current_stop_index(self, van_id: int) -> int: - """ - Get the current stop of the van state (as the index of the van state's stop list) tied to - the van ID (if it exists). Should throw an exception if the van state does not exist. - """ - - @abstractmethod - def set_current_stop_index(self, van_id: int, index: int): - """ - Update the current stop of the van state (as the index of the van state's stop list) tied to - the van ID (if it exists). This will extend the lifespan of the van state in the cache. - Should throw an exception if the van state does not exist. - """ diff --git a/backend/src/vantracking/cachetools/__init__.py b/backend/src/vantracking/cachetools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/vantracking/cachetools/cache.py b/backend/src/vantracking/cachetools/cache.py deleted file mode 100644 index 8beece68..00000000 --- a/backend/src/vantracking/cachetools/cache.py +++ /dev/null @@ -1,90 +0,0 @@ -import time -from collections import deque -from typing import Any, Optional - -from cachetools import FIFOCache -from src.model.stop import Stop -from src.vantracking.cache import VanStateCache -from src.vantracking.cachetools.ttl import TTL -from src.vantracking.cachetools.ttlqueue import TTLQueue -from src.vantracking.cachetools.ttlvanstate import TTLVanState -from src.vantracking.state import Location - - -class CachetoolsVanStateCache(VanStateCache): - """ - A van state cache implementation that uses the cachetools library and some custom caches - to implement the required functionality. - """ - - def __init__(self, config={}): - if "maxsize" not in config: - config["maxsize"] = 100 - if "ttl" not in config: - config["ttl"] = 300 - self.ttl = int(config["ttl"]) - # TTLCache cannot be used since it only renews items when they are reassigned. We as a - # result only want to guard against the cache being too full, hence the usage of a more - # basic FIFO cache. - self.cache = FIFOCache(maxsize=int(config["maxsize"])) - - def __contains__(self, van_id: int): - return van_id in self.cache - - def add(self, van_id: int, stops: list[Stop]): - # Remove van states that haven't been updated in a while while we can safely mutate - # the cache. - self.__expire() - self.cache[van_id] = TTL( - TTLVanState( - locations=TTLQueue(ttl=self.ttl), - stops=stops, - current_stop_index=-1, - ) - ) - - def get_locations(self, van_id: int) -> list[Location]: - entry = self.cache.get(van_id) - if entry is None: - raise KeyError("Van state does not exist") - # We aren't working with a location list, must convert it first. - return list(iter(entry.value.locations)) - - def push_location(self, van_id: int, location: Location): - # Remove van states that haven't been updated in a while while we can safely mutate - # the cache. - self.__expire() - entry = self.cache[van_id] - if entry is None: - raise KeyError("Van state does not exist") - entry.value.locations.push(location) - # This van state is still being updated by something, so we should extend its lifespan. - entry.refresh() - - def get_stops(self, van_id: int) -> list[Stop]: - entry = self.cache.get(van_id) - if entry is None: - raise KeyError("Van state does not exist") - return entry.value.stops - - def get_current_stop_index(self, van_id: int) -> int: - entry = self.cache.get(van_id) - if entry is None: - raise KeyError("Van state does not exist") - return entry.value.current_stop_index - - def set_current_stop_index(self, van_id: int, index: int): - # Remove van states that haven't been updated in a while while we can safely mutate - # the cache. - self.__expire() - entry = self.cache[van_id] - if entry is None: - raise KeyError("Van state does not exist") - entry.value.current_stop_index = index - # This van state is still being updated by something, so we should extend its lifespan. - entry.refresh() - - def __expire(self): - for van_id, state in self.cache.items(): - if state.expired(self.ttl): - del self.cache[van_id] diff --git a/backend/src/vantracking/cachetools/ttl.py b/backend/src/vantracking/cachetools/ttl.py deleted file mode 100644 index 3e1b791c..00000000 --- a/backend/src/vantracking/cachetools/ttl.py +++ /dev/null @@ -1,27 +0,0 @@ -import time -from typing import Any - - -class TTL: - """ - Generic wrapper around a time-to-live (TTL) value that will expire after a certain amount of time - defined by the class containing an instance of this. - """ - - def __init__(self, value: Any): - # It's best to use monotonic time for this to avoid accidentally expiring items due to - # system clock changes. - self.timestamp = time.monotonic() - self.value = value - - def refresh(self): - """ - Renews the lifespan of this item with a new timestamp. - """ - self.timestamp = time.monotonic() - - def expired(self, ttl: int): - """ - Returns whether this item has expired based on the TTL value specified. - """ - return self.timestamp < time.monotonic() - ttl diff --git a/backend/src/vantracking/cachetools/ttlqueue.py b/backend/src/vantracking/cachetools/ttlqueue.py deleted file mode 100644 index 7bd0271f..00000000 --- a/backend/src/vantracking/cachetools/ttlqueue.py +++ /dev/null @@ -1,45 +0,0 @@ -from collections import deque -from typing import Any - -from src.vantracking.cachetools.ttl import TTL - - -class TTLQueue: - """ - A queue-like data structure with time-to-live (TTL) functionality. Items are appended to the front, and - then popped from the back when they are older than the TTL value specified. Note that the removal of the - items only occurs when new items are added, but otherwise any view of the queue will only show items that - have not been expired. - """ - - def __init__(self, ttl: int): - self.ttl = ttl - self.queue: deque = deque() - - def __contains__(self, key): - for item in self.queue: - if not item.expired(self.ttl) and item.value == key: - return True - return False - - def __iter__(self): - return map( - lambda item: item.value, - filter(lambda item: not item.expired(self.ttl), self.queue), - ) - - def push(self, value: Any): - """ - Pushes a new item to the front of the queue while removing any expired items from the back of the - queue. - """ - self.__expire() - self.queue.append(TTL(value)) - - def __expire(self): - while self.queue: - item = self.queue[0] - if item.expired(self.ttl): - self.queue.popleft() - else: - break diff --git a/backend/src/vantracking/cachetools/ttlvanstate.py b/backend/src/vantracking/cachetools/ttlvanstate.py deleted file mode 100644 index 1d8ab7cb..00000000 --- a/backend/src/vantracking/cachetools/ttlvanstate.py +++ /dev/null @@ -1,9 +0,0 @@ -from src.model.stop import Stop -from src.vantracking.cachetools.ttlqueue import TTLQueue - - -class TTLVanState: - def __init__(self, locations: TTLQueue, stops: list[Stop], current_stop_index: int): - self.locations = locations - self.stops = stops - self.current_stop_index = current_stop_index diff --git a/backend/src/vantracking/coordinate.py b/backend/src/vantracking/coordinate.py deleted file mode 100644 index 4578c1f0..00000000 --- a/backend/src/vantracking/coordinate.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class Coordinate(BaseModel): - latitude: float - longitude: float diff --git a/backend/src/vantracking/factory.py b/backend/src/vantracking/factory.py deleted file mode 100644 index e0b98561..00000000 --- a/backend/src/vantracking/factory.py +++ /dev/null @@ -1,27 +0,0 @@ -import os - -from src.vantracking.cachetools.cache import CachetoolsVanStateCache -from src.vantracking.memcached.cache import MemcachedVanStateCache -from src.vantracking.tracker import VanTracker - - -def van_tracker() -> VanTracker: - """ - Create a new van manager from the environment variable configuration. This will raise a ValueError if the - configuration is invalid. - """ - - config = {} - for key in os.environ: - if key.startswith("CACHE_"): - config[key[6:].lower()] = os.environ[key] - - if "type" not in config: - raise ValueError("type not in config") - - if config["type"] == "ttl": - return VanTracker(CachetoolsVanStateCache(config)) - elif config["type"] == "memcached": - return VanTracker(MemcachedVanStateCache(config)) - else: - raise ValueError("Invalid cache type") diff --git a/backend/src/vantracking/location.py b/backend/src/vantracking/location.py deleted file mode 100644 index 4a43e7e4..00000000 --- a/backend/src/vantracking/location.py +++ /dev/null @@ -1,9 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel -from src.vantracking.coordinate import Coordinate - - -class Location(BaseModel): - timestamp: datetime - coordinate: Coordinate diff --git a/backend/src/vantracking/memcached/__init__.py b/backend/src/vantracking/memcached/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/vantracking/memcached/cache.py b/backend/src/vantracking/memcached/cache.py deleted file mode 100644 index f91af183..00000000 --- a/backend/src/vantracking/memcached/cache.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Optional - -from src.model.stop import Stop -from src.vantracking.cache import VanStateCache -from src.vantracking.state import Location - - -class MemcachedVanStateCache(VanStateCache): - def __init__(self, config={}): - raise NotImplementedError() - - def __contains__(self, van_id: int): - raise NotImplementedError() - - def add(self, van_id: int, stops: list[Stop]): - raise NotImplementedError() - - def get_locations(self, van_id: int) -> list[Location]: - raise NotImplementedError() - - def push_location(self, van_id: int, location: Location): - raise NotImplementedError() - - def get_stops(self, van_id: int) -> list[Stop]: - raise NotImplementedError() - - def get_current_stop_index(self, van_id: int) -> int: - raise NotImplementedError() - - def set_current_stop_index(self, van_id: int, index: int): - raise NotImplementedError() diff --git a/backend/src/vantracking/state.py b/backend/src/vantracking/state.py deleted file mode 100644 index 43a6e0eb..00000000 --- a/backend/src/vantracking/state.py +++ /dev/null @@ -1,11 +0,0 @@ -from datetime import timedelta - -from src.model.stop import Stop -from src.vantracking.location import Location - - -class VanState: - def __init__(self, location: Location, stop: Stop, seconds_to_next_stop: timedelta): - self.location = location - self.next_stop = stop - self.seconds_to_next_stop = seconds_to_next_stop diff --git a/backend/src/vantracking/tracker.py b/backend/src/vantracking/tracker.py deleted file mode 100644 index 97161baf..00000000 --- a/backend/src/vantracking/tracker.py +++ /dev/null @@ -1,141 +0,0 @@ -from datetime import datetime, timedelta -from math import cos, radians, sqrt -from typing import Optional - -from src.model.stop import Stop -from src.vantracking.cache import VanStateCache -from src.vantracking.coordinate import Coordinate -from src.vantracking.location import Location -from src.vantracking.state import VanState - -THRESHOLD_RADIUS_M = 30.48 # 100 ft -THRESHOLD_TIME = timedelta(seconds=30) -AVERAGE_VAN_SPEED_MPS = 8.9408 # 20 mph - - -class VanTracker: - """ - General class for managing the current location and time estimates of all vans. This is transient state, - hence why it's kept separate from the stateless route handlers and database. Create an instance with - factory.van_tracker(). - """ - - def __init__(self, cache: VanStateCache): - self.cache = cache - - def __contains__(self, van_id: int): - return van_id in self.cache - - def init_van(self, van_id: int, stops: list[Stop]): - """ - Initialize a van state tied to the van ID. The stop list will be used for time estimates, so it should be in the - exact order the van will visit each stop. The van state must be consistently updated with push_location or it - will be removed from the cache. - """ - self.cache.add(van_id, stops) - - def get_van(self, van_id: int) -> Optional[VanState]: - """ - Get the current van state tied to the van ID (if it exists). This will return None if the van state is not - in the cache. - """ - - if van_id not in self.cache: - return None - locations = self.cache.get_locations(van_id) - if not locations: - return None - # Only expose the first location for simplicity. - location = locations[-1] - stops = self.cache.get_stops(van_id) - current_stop_index = self.cache.get_current_stop_index(van_id) - # Assuming that the route loops, find the next stop this van is assumed to be going to. This is what - # we will estimate the time to. - next_stop = stops[(current_stop_index + 1) % len(stops)] - next_stop_distance_meters = _distance_meters( - location.coordinate, - Coordinate(latitude=next_stop.lat, longitude=next_stop.lon), - ) - # Don't be fancy, just divide the difference in distance by the (guessed) average speed of the van. - time_to = timedelta(seconds=next_stop_distance_meters / AVERAGE_VAN_SPEED_MPS) - return VanState(location=location, stop=next_stop, seconds_to_next_stop=time_to) - - def push_location(self, van_id: int, location: Location): - """ - Push a new location to the van state tied to the van ID (if it exists). This will extend the lifespan of the van - state in the cache. This will also update the current stop and time estimate of the van as needed. - """ - - if van_id not in self.cache: - return None - self.cache.push_location(van_id, location) - - # To find an accurate time estimate for a stop, we need to remove vans that are not arriving at a stop, either - # because they aren't arriving at the stop or because they have departed it. We achieve this currently by - # implementing a state machine that tracks the (guessed) current stop of each van. We can then use that to find - # the next logical stop and estimate the time to arrive to that. - - stops = self.cache.get_stops(van_id) - current_stop_index = self.cache.get_current_stop_index(van_id) - # We want to consider all of the stops that are coming up for this van, as that allows to handle cases where a - # stop is erroneously skipped. Also make sure we include subsequent stops that wrap around. The wrap around slice - # needs to be bounded to 0 to prevent a negative index causing weird slicing behavior. - subsequent_stops = ( - stops[current_stop_index + 1 :] + stops[: max(current_stop_index - 1, 0)] - ) - locations = self.cache.get_locations(van_id) - for i, stop in enumerate(subsequent_stops): - longest_subset: list[datetime] = [] - current_subset: list[datetime] = [] - - # Find the longest consequtive subset (i.e streak) where the distance of the past couple of - # van locations is consistently within this stop's radius. - for location in locations: - stop_distance_meters = _distance_meters( - location.coordinate, - Coordinate(latitude=stop.lat, longitude=stop.lon), - ) - if stop_distance_meters < THRESHOLD_RADIUS_M: - current_subset.append(location.timestamp) - else: - if len(current_subset) > len(longest_subset): - longest_subset = current_subset - current_subset = [] - if len(current_subset) > len(longest_subset): - longest_subset = current_subset - - if longest_subset: - # A streak exists, find the overall duration that this van was within the stop's radius. Since locations - # are ordered from oldest to newest, we can just subtract the first and last timestamps. - duration = longest_subset[-1] - longest_subset[0] - else: - # No streak, so we weren't at the stop for any amount of time. - duration = timedelta(seconds=0) - - if duration >= THRESHOLD_TIME: - # We were at this stop for long enough, move to it. Since the stops iterated through are relative to the - # current stop, we have to add the current stop index to the current index in the loop to get the actual - # stop index. - # Note: It's possible that the van was at another stop's radius for even longer, but this is not considered - # until real-world testing shows this edge case to be important. - self.cache.set_current_stop_index( - van_id, (current_stop_index + i + 1) % len(stops) - ) - break - - -KM_LAT_RATIO = 111.32 # km/degree latitude -EARTH_CIRCUFERENCE_KM = 40075 # km -DEGREES_IN_CIRCLE = 360 # degrees - - -def _distance_meters(a: Coordinate, b: Coordinate) -> float: - dlat = b.latitude - a.latitude - dlon = b.longitude - a.longitude - - # Simplified distance calculation that assumes the earth is a sphere. This is good enough for our purposes. - # https://stackoverflow.com/a/39540339 - dlatkm = dlat * KM_LAT_RATIO - dlonkm = dlon * EARTH_CIRCUFERENCE_KM * cos(radians(a.latitude)) / DEGREES_IN_CIRCLE - - return sqrt(dlatkm**2 + dlonkm**2) * 1000 diff --git a/backend/tests/test_ridership.py b/backend/tests/test_ridership.py index 3ed3cbbb..da7169ac 100644 --- a/backend/tests/test_ridership.py +++ b/backend/tests/test_ridership.py @@ -1,360 +1,360 @@ -import struct -from datetime import datetime, timedelta, timezone -from unittest.mock import MagicMock - -import pytest -from src.handlers.ridership import ( - RidershipFilterModel, - get_ridership, - post_ridership_stats, -) -from src.hardware import HardwareErrorCode, HardwareHTTPException, HardwareOKResponse -from src.model.analytics import Analytics -from src.model.route import Route -from src.model.van import Van - - -@pytest.fixture -def mock_van_model(): - return Van(id=1, route_id=1, wheelchair=False) - - -def new_mock_ridership(time: datetime): - return Analytics( - van_id=1, - route_id=1, - entered=5, - exited=3, - lat=37.7749, - lon=-122.4194, - datetime=time, - ) - - -def mock_analytics_body(analytics: Analytics, time: datetime): - async def inner(): - return struct.pack( - " 0 - - -@pytest.mark.asyncio -async def test_post_ridership_stats_without_prior(mock_route_args, mock_van_model): - # Arrange - now = datetime.now(timezone.utc).replace(microsecond=0) - new_ridership = new_mock_ridership(now) - mock_route_args.req.body = mock_analytics_body(new_ridership, now) - mock_route_args.session.add(mock_van_model) - mock_route_args.session.commit() - - # Act - response = await post_ridership_stats(mock_route_args.req, mock_van_model.id) - - # Assert - assert response == HardwareOKResponse() - assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() > 0 - - -@pytest.mark.asyncio -async def test_post_ridership_stats_too_far_in_past(mock_route_args, mock_van_model): - # Arrange - now = datetime.now(timezone.utc).replace(microsecond=0) - timedelta(minutes=2) - new_ridership = new_mock_ridership(now) - mock_route_args.req.body = mock_analytics_body(new_ridership, now) - mock_route_args.session.add(mock_van_model) - mock_route_args.session.commit() - - # Act - with pytest.raises(HardwareHTTPException) as e: - await post_ridership_stats(mock_route_args.req, mock_van_model.id) - - # Assert - assert e.value.status_code == 400 - assert e.value.error_code == HardwareErrorCode.TIMESTAMP_TOO_FAR_IN_PAST - assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 - - -@pytest.mark.asyncio -async def test_post_ridership_stats_in_future(mock_route_args, mock_van_model): - # Arrange - now = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=2) - new_ridership = new_mock_ridership(now) - mock_route_args.req.body = mock_analytics_body(new_ridership, now) - mock_route_args.session.add(mock_van_model) - mock_route_args.session.commit() - - # Act - with pytest.raises(HardwareHTTPException) as e: - await post_ridership_stats(mock_route_args.req, mock_van_model.id) - - # Assert - assert e.value.status_code == 400 - assert e.value.error_code == HardwareErrorCode.TIMESTAMP_IN_FUTURE - assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 - - -@pytest.mark.asyncio -async def test_post_ridership_van_not_active_invalid_param( - mock_route_args, mock_van_model -): - # Arrange - now = datetime.now(timezone.utc).replace(microsecond=0) - new_ridership = new_mock_ridership(now) - mock_route_args.req.body = mock_analytics_body(new_ridership, now) - mock_route_args.session.add(mock_van_model) - mock_route_args.session.commit() - - # Act - with pytest.raises(HardwareHTTPException) as e: - await post_ridership_stats(mock_route_args.req, 16) - - # Assert - assert e.value.status_code == 404 - assert e.value.error_code == HardwareErrorCode.VAN_NOT_ACTIVE - assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 - - -@pytest.mark.asyncio -async def test_post_ridership_van_not_active_valid_param(mock_route_args): - # Arrange - now = datetime.now(timezone.utc).replace(microsecond=0) - new_ridership = new_mock_ridership(now) - mock_route_args.req.body = mock_analytics_body(new_ridership, now) - - # Act - with pytest.raises(HardwareHTTPException) as e: - await post_ridership_stats(mock_route_args.req, 1) - - # Assert - assert e.value.status_code == 404 - assert e.value.error_code == HardwareErrorCode.VAN_NOT_ACTIVE - assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 - - -@pytest.mark.asyncio -async def test_post_ridership_stats_not_most_recent(mock_route_args, mock_van_model): - # Arrange - now = datetime.now(timezone.utc).replace(microsecond=0) - new_ridership = new_mock_ridership(now) - prior_ridership = new_mock_ridership(now + timedelta(minutes=1)) - mock_route_args.req.body = mock_analytics_body(new_ridership, now) - mock_route_args.session.add(mock_van_model) - mock_route_args.session.add(prior_ridership) - mock_route_args.session.commit() - - # Act - with pytest.raises(HardwareHTTPException) as e: - await post_ridership_stats(mock_route_args.req, mock_van_model.id) - - # Assert - assert e.value.status_code == 400 - assert e.value.error_code == HardwareErrorCode.TIMESTAMP_NOT_MOST_RECENT - assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 - - -@pytest.mark.parametrize( - "filter_params, expected_count", - [ - ( - RidershipFilterModel( - route_id=None, van_id=None, start_timestamp=None, end_timestamp=None - ), - 10, - ), # No filters, expect all records - ( - RidershipFilterModel( - route_id=1, van_id=None, start_timestamp=None, end_timestamp=None - ), - 5, - ), # Filter by route_id - ( - RidershipFilterModel( - route_id=None, van_id=1, start_timestamp=None, end_timestamp=None - ), - 5, - ), # Filter by van_id - ( - RidershipFilterModel( - route_id=None, - van_id=None, - start_timestamp=int(datetime(2022, 1, 1).timestamp()), - end_timestamp=None, - ), - 10, - ), # Filter by start_date - ( - RidershipFilterModel( - route_id=None, - van_id=None, - start_timestamp=None, - end_timestamp=int(datetime(2022, 1, 6).timestamp()), - ), - 10, - ), # Filter by end_date - ( - RidershipFilterModel( - route_id=1, - van_id=1, - start_timestamp=int(datetime(2022, 1, 1).timestamp()), - end_timestamp=int(datetime(2022, 1, 6).timestamp()), - ), - 5, - ), # Filter by all parameters - ( - RidershipFilterModel( - route_id=99, van_id=None, start_timestamp=None, end_timestamp=None - ), - 0, - ), # Filter by non-existent route_id - ( - RidershipFilterModel( - route_id=None, van_id=99, start_timestamp=None, end_timestamp=None - ), - 0, - ), # Filter by non-existent van_id - ( - RidershipFilterModel( - route_id=None, - van_id=None, - start_timestamp=int(datetime(2023, 1, 1).timestamp()), - end_timestamp=None, - ), - 0, - ), # Filter by future start_date - ( - RidershipFilterModel( - route_id=None, - van_id=None, - start_timestamp=None, - end_timestamp=int(datetime(2021, 1, 1).timestamp()), - ), - 0, - ), # Filter by past end_date - ], -) -def test_get_ridership(mock_route_args, filter_params, expected_count): - # Arrange - mock_route_args.session.add_all( - [ - Analytics( - van_id=1, - route_id=1, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 2, tzinfo=timezone.utc), - ), - Analytics( - van_id=1, - route_id=1, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 3, tzinfo=timezone.utc), - ), - Analytics( - van_id=1, - route_id=1, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 4, tzinfo=timezone.utc), - ), - Analytics( - van_id=1, - route_id=1, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 5, tzinfo=timezone.utc), - ), - Analytics( - van_id=1, - route_id=1, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 6, tzinfo=timezone.utc), - ), - Analytics( - van_id=2, - route_id=2, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 2, tzinfo=timezone.utc), - ), - Analytics( - van_id=2, - route_id=2, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 3, tzinfo=timezone.utc), - ), - Analytics( - van_id=2, - route_id=2, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 4, tzinfo=timezone.utc), - ), - Analytics( - van_id=2, - route_id=2, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 5, tzinfo=timezone.utc), - ), - Analytics( - van_id=2, - route_id=2, - entered=1, - exited=1, - lat=37.7749, - lon=-122.4194, - datetime=datetime(2022, 1, 6, tzinfo=timezone.utc), - ), - ] - ) - mock_route_args.session.commit() - - response = get_ridership(mock_route_args.req, filter_params) - assert len(response) == expected_count +# import struct +# from datetime import datetime, timedelta, timezone +# from unittest.mock import MagicMock + +# import pytest +# from src.handlers.ridership import ( +# RidershipFilterModel, +# get_ridership, +# post_ridership_stats, +# ) +# from src.hardware import HardwareErrorCode, HardwareHTTPException, HardwareOKResponse +# from src.model.analytics import Analytics +# from src.model.route import Route +# from src.model.van import Van + + +# @pytest.fixture +# def mock_van_model(): +# return Van(id=1, route_id=1, wheelchair=False) + + +# def new_mock_ridership(time: datetime): +# return Analytics( +# van_id=1, +# route_id=1, +# entered=5, +# exited=3, +# lat=37.7749, +# lon=-122.4194, +# datetime=time, +# ) + + +# def mock_analytics_body(analytics: Analytics, time: datetime): +# async def inner(): +# return struct.pack( +# " 0 + + +# @pytest.mark.asyncio +# async def test_post_ridership_stats_without_prior(mock_route_args, mock_van_model): +# # Arrange +# now = datetime.now(timezone.utc).replace(microsecond=0) +# new_ridership = new_mock_ridership(now) +# mock_route_args.req.body = mock_analytics_body(new_ridership, now) +# mock_route_args.session.add(mock_van_model) +# mock_route_args.session.commit() + +# # Act +# response = await post_ridership_stats(mock_route_args.req, mock_van_model.id) + +# # Assert +# assert response == HardwareOKResponse() +# assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() > 0 + + +# @pytest.mark.asyncio +# async def test_post_ridership_stats_too_far_in_past(mock_route_args, mock_van_model): +# # Arrange +# now = datetime.now(timezone.utc).replace(microsecond=0) - timedelta(minutes=2) +# new_ridership = new_mock_ridership(now) +# mock_route_args.req.body = mock_analytics_body(new_ridership, now) +# mock_route_args.session.add(mock_van_model) +# mock_route_args.session.commit() + +# # Act +# with pytest.raises(HardwareHTTPException) as e: +# await post_ridership_stats(mock_route_args.req, mock_van_model.id) + +# # Assert +# assert e.value.status_code == 400 +# assert e.value.error_code == HardwareErrorCode.TIMESTAMP_TOO_FAR_IN_PAST +# assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 + + +# @pytest.mark.asyncio +# async def test_post_ridership_stats_in_future(mock_route_args, mock_van_model): +# # Arrange +# now = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=2) +# new_ridership = new_mock_ridership(now) +# mock_route_args.req.body = mock_analytics_body(new_ridership, now) +# mock_route_args.session.add(mock_van_model) +# mock_route_args.session.commit() + +# # Act +# with pytest.raises(HardwareHTTPException) as e: +# await post_ridership_stats(mock_route_args.req, mock_van_model.id) + +# # Assert +# assert e.value.status_code == 400 +# assert e.value.error_code == HardwareErrorCode.TIMESTAMP_IN_FUTURE +# assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 + + +# @pytest.mark.asyncio +# async def test_post_ridership_van_not_active_invalid_param( +# mock_route_args, mock_van_model +# ): +# # Arrange +# now = datetime.now(timezone.utc).replace(microsecond=0) +# new_ridership = new_mock_ridership(now) +# mock_route_args.req.body = mock_analytics_body(new_ridership, now) +# mock_route_args.session.add(mock_van_model) +# mock_route_args.session.commit() + +# # Act +# with pytest.raises(HardwareHTTPException) as e: +# await post_ridership_stats(mock_route_args.req, 16) + +# # Assert +# assert e.value.status_code == 404 +# assert e.value.error_code == HardwareErrorCode.VAN_NOT_ACTIVE +# assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 + + +# @pytest.mark.asyncio +# async def test_post_ridership_van_not_active_valid_param(mock_route_args): +# # Arrange +# now = datetime.now(timezone.utc).replace(microsecond=0) +# new_ridership = new_mock_ridership(now) +# mock_route_args.req.body = mock_analytics_body(new_ridership, now) + +# # Act +# with pytest.raises(HardwareHTTPException) as e: +# await post_ridership_stats(mock_route_args.req, 1) + +# # Assert +# assert e.value.status_code == 404 +# assert e.value.error_code == HardwareErrorCode.VAN_NOT_ACTIVE +# assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 + + +# @pytest.mark.asyncio +# async def test_post_ridership_stats_not_most_recent(mock_route_args, mock_van_model): +# # Arrange +# now = datetime.now(timezone.utc).replace(microsecond=0) +# new_ridership = new_mock_ridership(now) +# prior_ridership = new_mock_ridership(now + timedelta(minutes=1)) +# mock_route_args.req.body = mock_analytics_body(new_ridership, now) +# mock_route_args.session.add(mock_van_model) +# mock_route_args.session.add(prior_ridership) +# mock_route_args.session.commit() + +# # Act +# with pytest.raises(HardwareHTTPException) as e: +# await post_ridership_stats(mock_route_args.req, mock_van_model.id) + +# # Assert +# assert e.value.status_code == 400 +# assert e.value.error_code == HardwareErrorCode.TIMESTAMP_NOT_MOST_RECENT +# assert mock_route_args.session.query(Analytics).filter_by(datetime=now).count() == 0 + + +# @pytest.mark.parametrize( +# "filter_params, expected_count", +# [ +# ( +# RidershipFilterModel( +# route_id=None, van_id=None, start_timestamp=None, end_timestamp=None +# ), +# 10, +# ), # No filters, expect all records +# ( +# RidershipFilterModel( +# route_id=1, van_id=None, start_timestamp=None, end_timestamp=None +# ), +# 5, +# ), # Filter by route_id +# ( +# RidershipFilterModel( +# route_id=None, van_id=1, start_timestamp=None, end_timestamp=None +# ), +# 5, +# ), # Filter by van_id +# ( +# RidershipFilterModel( +# route_id=None, +# van_id=None, +# start_timestamp=int(datetime(2022, 1, 1).timestamp()), +# end_timestamp=None, +# ), +# 10, +# ), # Filter by start_date +# ( +# RidershipFilterModel( +# route_id=None, +# van_id=None, +# start_timestamp=None, +# end_timestamp=int(datetime(2022, 1, 6).timestamp()), +# ), +# 10, +# ), # Filter by end_date +# ( +# RidershipFilterModel( +# route_id=1, +# van_id=1, +# start_timestamp=int(datetime(2022, 1, 1).timestamp()), +# end_timestamp=int(datetime(2022, 1, 6).timestamp()), +# ), +# 5, +# ), # Filter by all parameters +# ( +# RidershipFilterModel( +# route_id=99, van_id=None, start_timestamp=None, end_timestamp=None +# ), +# 0, +# ), # Filter by non-existent route_id +# ( +# RidershipFilterModel( +# route_id=None, van_id=99, start_timestamp=None, end_timestamp=None +# ), +# 0, +# ), # Filter by non-existent van_id +# ( +# RidershipFilterModel( +# route_id=None, +# van_id=None, +# start_timestamp=int(datetime(2023, 1, 1).timestamp()), +# end_timestamp=None, +# ), +# 0, +# ), # Filter by future start_date +# ( +# RidershipFilterModel( +# route_id=None, +# van_id=None, +# start_timestamp=None, +# end_timestamp=int(datetime(2021, 1, 1).timestamp()), +# ), +# 0, +# ), # Filter by past end_date +# ], +# ) +# def test_get_ridership(mock_route_args, filter_params, expected_count): +# # Arrange +# mock_route_args.session.add_all( +# [ +# Analytics( +# van_id=1, +# route_id=1, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 2, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=1, +# route_id=1, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 3, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=1, +# route_id=1, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 4, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=1, +# route_id=1, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 5, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=1, +# route_id=1, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 6, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=2, +# route_id=2, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 2, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=2, +# route_id=2, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 3, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=2, +# route_id=2, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 4, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=2, +# route_id=2, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 5, tzinfo=timezone.utc), +# ), +# Analytics( +# van_id=2, +# route_id=2, +# entered=1, +# exited=1, +# lat=37.7749, +# lon=-122.4194, +# datetime=datetime(2022, 1, 6, tzinfo=timezone.utc), +# ), +# ] +# ) +# mock_route_args.session.commit() + +# response = get_ridership(mock_route_args.req, filter_params) +# assert len(response) == expected_count diff --git a/eas.json b/eas.json new file mode 100644 index 00000000..e766a30f --- /dev/null +++ b/eas.json @@ -0,0 +1,18 @@ +{ + "cli": { + "version": ">= 7.5.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": {} + }, + "submit": { + "production": {} + } +} diff --git a/frontend/app.config.js b/frontend/app.config.js index 84a078bf..fcadfe11 100644 --- a/frontend/app.config.js +++ b/frontend/app.config.js @@ -25,6 +25,7 @@ module.exports = { ios: { supportsTablet: true, bundleIdentifier: "edu.mines.orecart.app", + buildNumber: "4", config: { googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY_IOS, }, @@ -35,7 +36,7 @@ module.exports = { }, android: { package: "edu.mines.orecart.app", - versionCode: 3, + versionCode: 4, adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png", backgroundColor: "#ffffff", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bad253ed..f2311771 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,17 +17,17 @@ "@reduxjs/toolkit": "^2.0.1", "d3": "^7.8.5", "d3-shape": "^3.2.0", - "expo": "~50.0.13", + "expo": "~50.0.17", "expo-checkbox": "~2.7.0", "expo-location": "~16.5.5", "expo-navigation-bar": "~2.8.1", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", - "expo-system-ui": "~2.9.3", + "expo-system-ui": "~2.9.4", "patch-package": "^8.0.0", "prettier": "^3.0.3", "react": "18.2.0", - "react-native": "0.73.5", + "react-native": "0.73.6", "react-native-drawer-layout": "^3.2.2", "react-native-gesture-handler": "~2.14.0", "react-native-maps": "1.10.0", @@ -165,16 +165,16 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.0.tgz", - "integrity": "sha512-QAH+vfvts51BCsNZ2PhY6HAggnlS6omLLFTsIpeqZk/MmJ6cW7tgz5yRv0fMJThcr6FmbMrENh1RgrWPTYA76g==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz", + "integrity": "sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==", "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-member-expression-to-functions": "^7.23.0", "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-replace-supers": "^7.24.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", "semver": "^6.3.1" @@ -314,11 +314,12 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.20", - "license": "MIT", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz", + "integrity": "sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==", "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.22.15", + "@babel/helper-member-expression-to-functions": "^7.23.0", "@babel/helper-optimise-call-expression": "^7.22.5" }, "engines": { @@ -359,8 +360,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "license": "MIT", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", "engines": { "node": ">=6.9.0" } @@ -373,8 +375,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.15", - "license": "MIT", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", "engines": { "node": ">=6.9.0" } @@ -484,13 +487,13 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.24.0.tgz", - "integrity": "sha512-LiT1RqZWeij7X+wGxCoYh3/3b8nVOX6/7BZ9wiQgAIyjoeQWdROaodJCgT+dwtbjHaz0r7bEbHJzjSbVfcOyjQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.24.1.tgz", + "integrity": "sha512-zPEvzFijn+hRvJuX2Vu3KbEBN39LN3f7tW3MQO2LsIs57B26KU+kUc82BdAktS1VCM6libzh45eKGI65lg0cpA==", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.0", + "@babel/helper-create-class-features-plugin": "^7.24.1", "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-decorators": "^7.24.0" + "@babel/plugin-syntax-decorators": "^7.24.1" }, "engines": { "node": ">=6.9.0" @@ -631,9 +634,9 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.24.0.tgz", - "integrity": "sha512-MXW3pQCu9gUiVGzqkGqsgiINDVYXoAnrY8FYF/rmb+OfufNF0zHMpHPN4ulRrinxYT8Vk/aZJxYqOKsDECjKAw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.24.1.tgz", + "integrity": "sha512-05RJdO/cCrtVWuAaSn1tS3bH8jbsJa/Y1uD186u6J4C/1mnHFxseeuWpsqr9anvo7TUulev7tm7GDwRV+VuhDw==", "dependencies": { "@babel/helper-plugin-utils": "^7.24.0" }, @@ -738,10 +741,11 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.22.5", - "license": "MIT", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", + "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1420,11 +1424,11 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz", - "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.1.tgz", + "integrity": "sha512-mvoQg2f9p2qlpDQRBC7M3c3XTr0k7cp/0+kFKKO/7Gtu0LSw16eKB+Fabe2bDT/UpsyasTBBkAnbdsLrkD5XMw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1434,14 +1438,15 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.22.15", - "license": "MIT", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-module-imports": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-jsx": "^7.22.5", - "@babel/types": "^7.22.15" + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" }, "engines": { "node": ">=6.9.0" @@ -1491,12 +1496,12 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz", - "integrity": "sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.1.tgz", + "integrity": "sha512-+pWEAaDJvSm9aFvJNpLiM2+ktl2Sn2U5DdyiWdZBxmLc6+xGt88dvFqsHiAiDS+8WqUwbDfkKz9jRxK3M0k+kA==", "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1808,16 +1813,16 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.23.3.tgz", - "integrity": "sha512-tbkHOS9axH6Ysf2OUEqoSZ6T3Fa2SrNH6WTWSPBboxKzdxNc9qOICeLXkNG0ZEwbQ1HY8liwOce4aN/Ceyuq6w==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.1.tgz", + "integrity": "sha512-eFa8up2/8cZXLIpkafhaADTXSnl7IsUFCYenRWrARBz0/qZwcT0RBXpys0LJU4+WfPoF2ZG6ew6s2V6izMCwRA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", - "@babel/plugin-transform-react-display-name": "^7.23.3", - "@babel/plugin-transform-react-jsx": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-transform-react-display-name": "^7.24.1", + "@babel/plugin-transform-react-jsx": "^7.23.4", "@babel/plugin-transform-react-jsx-development": "^7.22.5", - "@babel/plugin-transform-react-pure-annotations": "^7.23.3" + "@babel/plugin-transform-react-pure-annotations": "^7.24.1" }, "engines": { "node": ">=6.9.0" @@ -1907,10 +1912,11 @@ } }, "node_modules/@babel/types": { - "version": "7.23.0", - "license": "MIT", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dependencies": { - "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, @@ -2071,14 +2077,14 @@ } }, "node_modules/@expo/cli": { - "version": "0.17.8", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.17.8.tgz", - "integrity": "sha512-yfkoghCltbGPDbRI71Qu3puInjXx4wO82+uhW82qbWLvosfIN7ep5Gr0Lq54liJpvlUG6M0IXM1GiGqcCyP12w==", + "version": "0.17.10", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.17.10.tgz", + "integrity": "sha512-Jw2wY+lsavP9GRqwwLqF/SvB7w2GZ4sWBMcBKTZ8F0lWjwmLGAUt4WYquf20agdmnY/oZUHvWNkrz/t3SflhnA==", "dependencies": { "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "0.0.5", "@expo/config": "~8.5.0", - "@expo/config-plugins": "~7.8.0", + "@expo/config-plugins": "~7.9.0", "@expo/devcert": "^1.0.0", "@expo/env": "~0.2.2", "@expo/image-utils": "^0.4.0", @@ -2087,7 +2093,7 @@ "@expo/osascript": "^2.0.31", "@expo/package-manager": "^1.1.1", "@expo/plist": "^0.1.0", - "@expo/prebuild-config": "6.7.4", + "@expo/prebuild-config": "6.8.1", "@expo/rudder-sdk-node": "1.1.1", "@expo/spawn-async": "1.5.0", "@expo/xcpretty": "^4.3.0", @@ -2156,6 +2162,97 @@ "expo-internal": "build/bin/cli" } }, + "node_modules/@expo/cli/node_modules/@expo/config-plugins": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-7.9.1.tgz", + "integrity": "sha512-ICt6Jed1J0tPYMQrJ8K5Qusgih2I6pZ2PU4VSvxsN3T4n97L13XpYV1vyq1Uc/HMl3UhOwldipmgpEbCfeDqsQ==", + "dependencies": { + "@expo/config-types": "^50.0.0-alpha.1", + "@expo/fingerprint": "^0.6.0", + "@expo/json-file": "~8.3.0", + "@expo/plist": "^0.1.0", + "@expo/sdk-runtime-versions": "^1.0.0", + "@react-native/normalize-color": "^2.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.1", + "find-up": "~5.0.0", + "getenv": "^1.0.0", + "glob": "7.1.6", + "resolve-from": "^5.0.0", + "semver": "^7.5.3", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/cli/node_modules/@expo/config-plugins/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/cli/node_modules/@expo/prebuild-config": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-6.8.1.tgz", + "integrity": "sha512-ptK9e0dcj1eYlAWV+fG+QkuAWcLAT1AmtEbj++tn7ZjEj8+LkXRM73LCOEGaF0Er8i8ZWNnaVsgGW4vjgP5ZsA==", + "dependencies": { + "@expo/config": "~8.5.0", + "@expo/config-plugins": "~7.9.0", + "@expo/config-types": "^50.0.0-alpha.1", + "@expo/image-utils": "^0.4.0", + "@expo/json-file": "^8.2.37", + "debug": "^4.3.1", + "fs-extra": "^9.0.0", + "resolve-from": "^5.0.0", + "semver": "7.5.3", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo-modules-autolinking": ">=0.8.1" + } + }, + "node_modules/@expo/cli/node_modules/@expo/prebuild-config/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/cli/node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/cli/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2228,6 +2325,17 @@ "node": ">=8" } }, + "node_modules/@expo/cli/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/@expo/cli/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2275,6 +2383,14 @@ "node": ">=8" } }, + "node_modules/@expo/cli/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/@expo/cli/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -2290,12 +2406,12 @@ } }, "node_modules/@expo/config": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-8.5.4.tgz", - "integrity": "sha512-ggOLJPHGzJSJHVBC1LzwXwR6qUn8Mw7hkc5zEKRIdhFRuIQ6s2FE4eOvP87LrNfDF7eZGa6tJQYsiHSmZKG+8Q==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-8.5.6.tgz", + "integrity": "sha512-wF5awSg6MNn1cb1lIgjnhOn5ov2TEUTnkAVCsOl0QqDwcP+YIerteSFwjn9V52UZvg58L+LKxpCuGbw5IHavbg==", "dependencies": { "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~7.8.2", + "@expo/config-plugins": "~7.9.0", "@expo/config-types": "^50.0.0", "@expo/json-file": "^8.2.37", "getenv": "^1.0.0", @@ -2438,6 +2554,83 @@ "@babel/highlight": "^7.10.4" } }, + "node_modules/@expo/config/node_modules/@expo/config-plugins": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-7.9.1.tgz", + "integrity": "sha512-ICt6Jed1J0tPYMQrJ8K5Qusgih2I6pZ2PU4VSvxsN3T4n97L13XpYV1vyq1Uc/HMl3UhOwldipmgpEbCfeDqsQ==", + "dependencies": { + "@expo/config-types": "^50.0.0-alpha.1", + "@expo/fingerprint": "^0.6.0", + "@expo/json-file": "~8.3.0", + "@expo/plist": "^0.1.0", + "@expo/sdk-runtime-versions": "^1.0.0", + "@react-native/normalize-color": "^2.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.1", + "find-up": "~5.0.0", + "getenv": "^1.0.0", + "glob": "7.1.6", + "resolve-from": "^5.0.0", + "semver": "^7.5.3", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@expo/config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@expo/config/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@expo/config/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@expo/config/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/@expo/config/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2463,6 +2656,17 @@ "node": ">=10" } }, + "node_modules/@expo/config/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@expo/config/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -2497,14 +2701,14 @@ } }, "node_modules/@expo/env": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-0.2.2.tgz", - "integrity": "sha512-m9nGuaSpzdvMzevQ1H60FWgf4PG5s4J0dfKUzdAGnDu7sMUerY/yUeDaA4+OBo3vBwGVQ+UHcQS9vPSMBNaPcg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-0.2.3.tgz", + "integrity": "sha512-a+uJ/e6MAVxPVVN/HbXU5qxzdqrqDwNQYxCfxtAufgmd5VZj54e5f3TJA3LEEUW3pTSZR8xK0H0EtVN297AZnw==", "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", - "dotenv": "~16.0.3", - "dotenv-expand": "~10.0.0", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", "getenv": "^1.0.0" } }, @@ -2852,9 +3056,9 @@ } }, "node_modules/@expo/metro-config": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.17.6.tgz", - "integrity": "sha512-WaC1C+sLX/Wa7irwUigLhng3ckmXIEQefZczB8DfYmleV6uhfWWo2kz/HijFBpV7FKs2cW6u8J/aBQpFkxlcqg==", + "version": "0.17.7", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.17.7.tgz", + "integrity": "sha512-3vAdinAjMeRwdhGWWLX6PziZdAPvnyJ6KVYqnJErHHqH0cA6dgAENT3Vq6PEM1H2HgczKr2d5yG9AMgwy848ow==", "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", @@ -6699,12 +6903,15 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "dev": true, - "license": "MIT", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6788,16 +6995,17 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", - "dev": true, - "license": "MIT", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -6847,9 +7055,12 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "dev": true, - "license": "MIT", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -6916,9 +7127,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-10.0.1.tgz", - "integrity": "sha512-uWIGmLfbP3dS5+8nesxaW6mQs41d4iP7X82ZwRdisB/wAhKQmuJM9Y1jQe4006uNYkw6Phf2TT03ykLVro7KuQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-10.0.2.tgz", + "integrity": "sha512-hg06qdSTK7MjKmFXSiq6cFoIbI3n3uT8a3NI2EZoISWhu+tedCj4DQduwi+3adFuRuYvAwECI0IYn/5iGh5zWQ==", "dependencies": { "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-transform-export-namespace-from": "^7.22.11", @@ -8144,30 +8355,78 @@ "resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz", "integrity": "sha512-+LSAiGFwQ9dRnRdOeaj7g47ZFJcOUPukAP8J3A3fuZ1g9Y44BG+P1sgApjLXTQPOzC4+7S9Wr8kXsfpINM4jpw==" }, - "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" - }, - "node_modules/debug": { - "version": "4.3.4", - "license": "MIT", + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", "dependencies": { - "ms": "2.1.2" + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" }, "engines": { - "node": ">=6.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + }, + "node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "engines": { "node": ">=0.10.0" } @@ -8255,7 +8514,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -8432,19 +8690,28 @@ } }, "node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "engines": { "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.6.tgz", + "integrity": "sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==", + "dependencies": { + "dotenv": "^16.4.4" + }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/ee-first": { @@ -8537,49 +8804,56 @@ } }, "node_modules/es-abstract": { - "version": "1.22.2", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.1", - "get-symbol-description": "^1.0.0", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.8", - "string.prototype.trimend": "^1.0.7", - "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -8607,14 +8881,25 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { - "version": "2.0.1", - "dev": true, - "license": "MIT", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", "dependencies": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -8630,7 +8915,6 @@ }, "node_modules/es-to-primitive": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.1.4", @@ -9273,23 +9557,23 @@ } }, "node_modules/expo": { - "version": "50.0.13", - "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.13.tgz", - "integrity": "sha512-p0FYrhUJZe92YOwOXx6GZ/WaxF6YtsLXtWkql9pFIIocYBN6iQ3OMGsbQCRSu0ao8rlxsk7HgQDEWK4D+y9tAg==", + "version": "50.0.17", + "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.17.tgz", + "integrity": "sha512-eD8Nh10BgVwecU7EVyogx7X314ajxVpJdFwkXhi341AD61S2WPX31NMHW82XGXas6dbDjdbgtaOMo5H/vylB7Q==", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "0.17.8", - "@expo/config": "8.5.4", - "@expo/config-plugins": "7.8.4", - "@expo/metro-config": "0.17.6", + "@expo/cli": "0.17.10", + "@expo/config": "8.5.6", + "@expo/config-plugins": "7.9.1", + "@expo/metro-config": "0.17.7", "@expo/vector-icons": "^14.0.0", - "babel-preset-expo": "~10.0.1", + "babel-preset-expo": "~10.0.2", "expo-asset": "~9.0.2", - "expo-file-system": "~16.0.8", + "expo-file-system": "~16.0.9", "expo-font": "~11.10.3", "expo-keep-awake": "~12.8.2", "expo-modules-autolinking": "1.10.3", - "expo-modules-core": "1.11.12", + "expo-modules-core": "1.11.13", "fbemitter": "^3.0.0", "whatwg-url-without-unicode": "8.0.0-3" }, @@ -9327,9 +9611,9 @@ } }, "node_modules/expo-file-system": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.8.tgz", - "integrity": "sha512-yDbVT0TUKd7ewQjaY5THum2VRFx2n/biskGhkUmLh3ai21xjIVtaeIzHXyv9ir537eVgt4ReqDNWi7jcXjdUcA==", + "version": "16.0.9", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.9.tgz", + "integrity": "sha512-3gRPvKVv7/Y7AdD9eHMIdfg5YbUn2zbwKofjsloTI5sEC57SLUFJtbLvUCz9Pk63DaSQ7WIE1JM0EASyvuPbuw==", "peerDependencies": { "expo": "*" } @@ -9466,9 +9750,9 @@ } }, "node_modules/expo-modules-core": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.12.tgz", - "integrity": "sha512-/e8g4kis0pFLer7C0PLyx98AfmztIM6gU9jLkYnB1pU9JAfQf904XEi3bmszO7uoteBQwSL6FLp1m3TePKhDaA==", + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.13.tgz", + "integrity": "sha512-2H5qrGUvmLzmJNPDOnovH1Pfk5H/S/V0BifBmOQyDc9aUh9LaDwkqnChZGIXv8ZHDW8JRlUW0QqyWxTggkbw1A==", "dependencies": { "invariant": "^2.2.4" } @@ -9502,9 +9786,9 @@ "integrity": "sha512-ddQEtCOgYHTLlFUe/yH67dDBIoct5VIULthyT3LRJbEwdpzAgueKsX2FYK02ldh440V87PWKCamh7R9evk1rrg==" }, "node_modules/expo-system-ui": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-2.9.3.tgz", - "integrity": "sha512-RNFNBLJ9lhnjOGrHhtfDc15Ry/lF+SA4kwulmHzYGqaTeYvsL9q0K0+m9qmxuDdrbKJkuurvzvjVylDNnKNFVg==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-2.9.4.tgz", + "integrity": "sha512-ExJ8AzEZjb/zbg6nRLrN/mqxWr6e4fAcT0LBN/YvPZljbMo23HU+/lPy0/YctF1tRRvQ3Z95ABSNjnx9ajQBjg==", "dependencies": { "@react-native/normalize-color": "^2.0.0", "debug": "^4.3.2" @@ -9513,6 +9797,124 @@ "expo": "*" } }, + "node_modules/expo/node_modules/@expo/config-plugins": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-7.9.1.tgz", + "integrity": "sha512-ICt6Jed1J0tPYMQrJ8K5Qusgih2I6pZ2PU4VSvxsN3T4n97L13XpYV1vyq1Uc/HMl3UhOwldipmgpEbCfeDqsQ==", + "dependencies": { + "@expo/config-types": "^50.0.0-alpha.1", + "@expo/fingerprint": "^0.6.0", + "@expo/json-file": "~8.3.0", + "@expo/plist": "^0.1.0", + "@expo/sdk-runtime-versions": "^1.0.0", + "@react-native/normalize-color": "^2.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.1", + "find-up": "~5.0.0", + "getenv": "^1.0.0", + "glob": "7.1.6", + "resolve-from": "^5.0.0", + "semver": "^7.5.3", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/expo/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/expo/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/expo/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/expo/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/expo/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/expo/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "license": "MIT" @@ -9758,7 +10160,6 @@ }, "node_modules/for-each": { "version": "0.3.3", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.1.3" @@ -9842,7 +10243,6 @@ }, "node_modules/function.prototype.name": { "version": "1.1.6", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.2", @@ -9859,7 +10259,6 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9918,12 +10317,13 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.0", - "dev": true, - "license": "MIT", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -9987,7 +10387,6 @@ }, "node_modules/globalthis": { "version": "1.0.3", - "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.1.3" @@ -10070,7 +10469,6 @@ }, "node_modules/has-bigints": { "version": "1.0.2", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10095,8 +10493,9 @@ } }, "node_modules/has-proto": { - "version": "1.0.1", - "license": "MIT", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "engines": { "node": ">= 0.4" }, @@ -10115,11 +10514,11 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "dev": true, - "license": "MIT", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -10360,12 +10759,12 @@ } }, "node_modules/internal-slot": { - "version": "1.0.5", - "dev": true, - "license": "MIT", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dependencies": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "hasown": "^2.0.0", "side-channel": "^1.0.4" }, "engines": { @@ -10403,13 +10802,15 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "dev": true, - "license": "MIT", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10422,7 +10823,6 @@ }, "node_modules/is-bigint": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "has-bigints": "^1.0.1" @@ -10433,7 +10833,6 @@ }, "node_modules/is-boolean-object": { "version": "1.1.2", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.2", @@ -10453,7 +10852,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10472,9 +10870,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-date-object": { "version": "1.0.5", - "dev": true, "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" @@ -10572,9 +10983,9 @@ } }, "node_modules/is-negative-zero": { - "version": "2.0.2", - "dev": true, - "license": "MIT", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "engines": { "node": ">= 0.4" }, @@ -10591,7 +11002,6 @@ }, "node_modules/is-number-object": { "version": "1.0.7", - "dev": true, "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" @@ -10631,8 +11041,8 @@ }, "node_modules/is-regex": { "version": "1.1.4", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -10645,11 +11055,14 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "dev": true, - "license": "MIT", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10665,7 +11078,6 @@ }, "node_modules/is-string": { "version": "1.0.7", - "dev": true, "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" @@ -10679,7 +11091,6 @@ }, "node_modules/is-symbol": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.2" @@ -10692,11 +11103,11 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "dev": true, - "license": "MIT", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -10729,7 +11140,6 @@ }, "node_modules/is-weakref": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.2" @@ -12773,9 +13183,9 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "dev": true, - "license": "MIT", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12788,12 +13198,12 @@ } }, "node_modules/object.assign": { - "version": "4.1.4", - "dev": true, - "license": "MIT", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, @@ -13516,10 +13926,18 @@ "node": ">=4.0.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "funding": [ { "type": "opencollective", @@ -13537,7 +13955,7 @@ "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -13864,9 +14282,9 @@ "license": "MIT" }, "node_modules/react-native": { - "version": "0.73.5", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.73.5.tgz", - "integrity": "sha512-iHgDArmF4CrhL0qTj+Rn+CBN5pZWUL9lUGl8ub+V9Hwu/vnzQQh8rTMVSwVd2sV6N76KjpE5a4TfIAHkpIHhKg==", + "version": "0.73.6", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.73.6.tgz", + "integrity": "sha512-oqmZe8D2/VolIzSPZw+oUd6j/bEmeRHwsLn1xLA5wllEYsZ5zNuMsDus235ONOnCRwexqof/J3aztyQswSmiaA==", "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-community/cli": "12.3.6", @@ -14256,13 +14674,14 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.1", - "dev": true, - "license": "MIT", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -14447,12 +14866,12 @@ "license": "BSD-3-Clause" }, "node_modules/safe-array-concat": { - "version": "1.0.1", - "dev": true, - "license": "MIT", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -14465,8 +14884,8 @@ }, "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, "node_modules/safe-buffer": { "version": "5.1.2", @@ -14480,14 +14899,17 @@ "optional": true }, "node_modules/safe-regex-test": { - "version": "1.0.0", - "dev": true, - "license": "MIT", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -14612,13 +15034,14 @@ } }, "node_modules/set-function-name": { - "version": "2.0.1", - "dev": true, - "license": "MIT", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dependencies": { - "define-data-property": "^1.0.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14668,13 +15091,17 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "dev": true, - "license": "MIT", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14754,9 +15181,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -14909,13 +15336,14 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.8", - "dev": true, - "license": "MIT", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -14925,26 +15353,29 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.7", - "dev": true, - "license": "MIT", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.7", - "dev": true, - "license": "MIT", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15324,9 +15755,14 @@ "license": "MIT" }, "node_modules/traverse": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", - "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", + "integrity": "sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==", + "dependencies": { + "gopd": "^1.0.1", + "typedarray.prototype.slice": "^1.0.3", + "which-typed-array": "^1.1.15" + }, "engines": { "node": ">= 0.4" }, @@ -15405,27 +15841,28 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "dev": true, - "license": "MIT", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "dev": true, - "license": "MIT", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -15435,15 +15872,16 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "dev": true, - "license": "MIT", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -15453,13 +15891,38 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.4", - "dev": true, - "license": "MIT", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz", + "integrity": "sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-errors": "^1.3.0", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-offset": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15501,7 +15964,6 @@ }, "node_modules/unbox-primitive": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.2", @@ -15755,7 +16217,6 @@ }, "node_modules/which-boxed-primitive": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "is-bigint": "^1.0.1", @@ -15774,15 +16235,15 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" }, "node_modules/which-typed-array": { - "version": "1.1.11", - "dev": true, - "license": "MIT", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" diff --git a/frontend/package.json b/frontend/package.json index 98960af8..cc8fafaf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,17 +19,17 @@ "@reduxjs/toolkit": "^2.0.1", "d3": "^7.8.5", "d3-shape": "^3.2.0", - "expo": "~50.0.13", + "expo": "~50.0.17", "expo-checkbox": "~2.7.0", "expo-location": "~16.5.5", "expo-navigation-bar": "~2.8.1", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", - "expo-system-ui": "~2.9.3", + "expo-system-ui": "~2.9.4", "patch-package": "^8.0.0", "prettier": "^3.0.3", "react": "18.2.0", - "react-native": "0.73.5", + "react-native": "0.73.6", "react-native-drawer-layout": "^3.2.2", "react-native-gesture-handler": "~2.14.0", "react-native-maps": "1.10.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4211f11..54d58baf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ const App = (): React.JSX.Element => ( ; @@ -67,6 +68,7 @@ const Home = ({ route, navigation }: HomeScreenProps): React.JSX.Element => { const [atLanding, setAtLanding] = useState(true); manageLocationMiddleware(); + manageArrivalEstimates(); return ( { }} > - + { + navigation.push("Stop", { stopId: stop.id }); + }} + /> { @@ -153,6 +161,7 @@ const Home = ({ route, navigation }: HomeScreenProps): React.JSX.Element => { {/* Should disable headers on these screens since they arent full size. */} { const typedInnerNavigation = innerNavigation as NavigationProp; setAtLanding(!typedInnerNavigation.canGoBack()); + + // TODO: Store sheet state in history so we can restore it when navigating back. }, })} > diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 693f6bb3..28a90b6b 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -2,6 +2,8 @@ import { configureStore } from "@reduxjs/toolkit"; import locationMiddleware from "../features/location/locationMiddleware"; import locationReducer from "../features/location/locationSlice"; +import { mapSlice } from "../features/map/mapSlice"; +import { arrivalsSlice } from "../features/vans/arrivalSlice"; import apiSlice from "./apiSlice"; @@ -13,6 +15,8 @@ const store = configureStore({ reducer: { [apiSlice.reducerPath]: apiSlice.reducer, location: locationReducer, + arrivals: arrivalsSlice.reducer, + map: mapSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware() diff --git a/frontend/src/common/components/Divider.tsx b/frontend/src/common/components/Divider.tsx index 183e3ca4..64e42dd2 100644 --- a/frontend/src/common/components/Divider.tsx +++ b/frontend/src/common/components/Divider.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { View, StyleSheet } from "react-native"; +import { StyleSheet, View } from "react-native"; import Color from "../style/color"; @@ -14,7 +14,7 @@ const styles = StyleSheet.create({ divider: { height: 1, backgroundColor: Color.csm.primary.pale_blue, - margin: 8, + marginVertical: 8, }, }); diff --git a/frontend/src/common/components/ErrorMessage.tsx b/frontend/src/common/components/ErrorMessage.tsx index e075e14e..c2fb9d8c 100644 --- a/frontend/src/common/components/ErrorMessage.tsx +++ b/frontend/src/common/components/ErrorMessage.tsx @@ -4,10 +4,15 @@ import { StyleSheet, Text, View, type ViewProps } from "react-native"; import RetryButton from "./RetryButton"; interface ErrorComponentProps extends ViewProps { + /** The error message to show. */ message: string; - retry: () => void; + /** Hook to retry fetching the query that failed. */ + retry: () => Promise; } +/** + * A standard error message and retry button indicator. + */ const ErrorMessage: React.FC = ({ message, retry, @@ -16,7 +21,11 @@ const ErrorMessage: React.FC = ({ return ( {message} - + { + retry().catch(() => {}); + }} + /> ); }; diff --git a/frontend/src/common/components/List.tsx b/frontend/src/common/components/List.tsx new file mode 100644 index 00000000..3edcaa94 --- /dev/null +++ b/frontend/src/common/components/List.tsx @@ -0,0 +1,87 @@ +import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; +import { useFocusEffect } from "@react-navigation/native"; +import { FlatList, RefreshControl, type ViewProps } from "react-native"; + +import { type Query } from "../query"; + +import Divider from "./Divider"; +import ErrorMessage from "./ErrorMessage"; +import SkeletonList from "./SkeletonList"; +import Spacer from "./Spacer"; + +export interface ListProps extends ViewProps { + /** Returns the header to render (Optional) */ + header?: () => React.JSX.Element; + /** Returns the item to render. */ + item: (item: T) => React.JSX.Element; + /** Returns the skeleton item to render. */ + itemSkeleton: () => React.JSX.Element; + /** Whether to show a divider or a spacer */ + divider: "line" | "space"; + /** Returns the key for the item. */ + keyExtractor: (item: T) => string; + /** Whether to use a bottom sheet or regular flat list */ + bottomSheet: boolean; + /** The query to use to fetch the data */ + query: Query; + /** The function to call to refresh the query. */ + refresh: () => Promise; + /** The error message to show if the query fails */ + errorMessage: string; +} + +/** + * A list component that handles the needs to asynchrnous queries, headers, separators, and other + * common functionality. Always use this component when you have a query or other complex list need, + * as it improves the UX. + */ +function List({ + header, + item, + itemSkeleton, + divider, + keyExtractor, + bottomSheet, + query, + refresh, + errorMessage, + ...props +}: ListProps): React.JSX.Element { + return query.isSuccess ? ( + bottomSheet ? ( + item(data)} + ListHeaderComponent={header} + ItemSeparatorComponent={divider === "line" ? Divider : Spacer} + focusHook={useFocusEffect} + keyExtractor={keyExtractor} + /> + ) : ( + item(data)} + ListHeaderComponent={header} + ItemSeparatorComponent={divider === "line" ? Divider : Spacer} + keyExtractor={keyExtractor} + refreshControl={ + { + refresh().catch(() => {}); + }} + /> + } + /> + ) + ) : query.isLoading ? ( + + ) : ( + + ); +} + +export default List; diff --git a/frontend/src/common/components/ParentChildList.tsx b/frontend/src/common/components/ParentChildList.tsx new file mode 100644 index 00000000..86985514 --- /dev/null +++ b/frontend/src/common/components/ParentChildList.tsx @@ -0,0 +1,88 @@ +import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; +import { useFocusEffect } from "@react-navigation/native"; +import { FlatList, type ViewProps } from "react-native"; + +import { type Query } from "../query"; + +import Divider from "./Divider"; +import ErrorMessage from "./ErrorMessage"; +import SkeletonList from "./SkeletonList"; + +export interface ParentChildListProps extends ViewProps { + /** Returns the header to render for the parent. */ + header: (parent: P) => React.JSX.Element; + /** Returns the skeleton header to render for the parent. */ + headerSkeleton?: () => React.JSX.Element; + /** Returns the item to render for the child. */ + item: (parent: P, child: C) => React.JSX.Element; + /** Returns the skeleton item to render for the child. */ + itemSkeleton: () => React.JSX.Element; + /** Whether to show a divider or a spacer */ + divider: "line" | "space"; + /** Returns the key for the item. */ + keyExtractor: (item: C) => string; + /** Whether to use a bottom sheet or regular flat list */ + bottomSheet: boolean; + /** Maps the parent data to the child data. */ + map: (parent: P) => C[]; + /** The query to use to fetch the data */ + query: Query

; + /** The function to call to refresh the query. */ + refresh: () => Promise; + /** The error message to show if the query fails */ + errorMessage: string; +} + +/** + * A list component that takes parent data to use in the header, and then child data from the parent + * to use as list itesm. Always use this component when you have a query or other complex list need + * within a detail context (i.e like a Route), as it improves the UX. + */ +function ParentChildList({ + header, + headerSkeleton, + item, + itemSkeleton, + divider, + keyExtractor, + bottomSheet, + map, + query, + refresh, + errorMessage, + ...props +}: ParentChildListProps): React.JSX.Element { + return query.isSuccess ? ( + bottomSheet ? ( + item(query.data, child)} + ListHeaderComponent={() => header(query.data)} + ItemSeparatorComponent={divider === "line" ? Divider : Divider} + focusHook={useFocusEffect} + keyExtractor={keyExtractor} + /> + ) : ( + item(query.data, child)} + ListHeaderComponent={() => header(query.data)} + ItemSeparatorComponent={divider === "line" ? Divider : Divider} + keyExtractor={keyExtractor} + /> + ) + ) : query.isLoading ? ( + + ) : ( + + ); +} + +export default ParentChildList; diff --git a/frontend/src/common/components/QueryText.tsx b/frontend/src/common/components/QueryText.tsx new file mode 100644 index 00000000..0b9173d4 --- /dev/null +++ b/frontend/src/common/components/QueryText.tsx @@ -0,0 +1,80 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import { StyleSheet, Text, View, type TextProps } from "react-native"; + +import { type Query } from "../query"; +import Color from "../style/color"; + +import TextSkeleton from "./TextSkeleton"; + +interface QueryTextProps extends TextProps { + /** + * The query to display the text for. + */ + query: Query; + /** + * A function that returns the text to display when the query is successful. + */ + body: (data: T) => React.JSX.Element; + /** + * The width of the skeleton text as a fraction of the screen width. + * Should be similar to the real loaded text length for a good user experience. + */ + skeletonWidth: number; + /** + * The error message to display when the query fails. + */ + error?: string; +} + +/** + * A text element that handles query state. Always use this component when you have + * a query, as it improves the UX. + */ +function QueryText({ + query, + body, + skeletonWidth, + error, + style, + ...props +}: QueryTextProps): React.JSX.Element { + if (query.isSuccess) { + return {body(query.data)}; + } + if (query.isLoading) { + return ( + + ); + } + if (query.isError && error !== undefined) { + return ( + + + + {error} + + + ); + } + return ; +} + +const styles = StyleSheet.create({ + errorContainer: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + error: { + color: Color.generic.alert.primary, + alignSelf: "center", + }, + errorIcon: {}, +}); + +export default QueryText; diff --git a/frontend/src/common/components/RetryButton.tsx b/frontend/src/common/components/RetryButton.tsx index 824c87d2..6082f6cd 100644 --- a/frontend/src/common/components/RetryButton.tsx +++ b/frontend/src/common/components/RetryButton.tsx @@ -1,17 +1,21 @@ import React from "react"; import { - TouchableHighlight, - Text, StyleSheet, + Text, + TouchableHighlight, type ViewProps, } from "react-native"; import Color from "../style/color"; interface RetryButtonProps extends ViewProps { + /** Called when the button is pressed */ retry: () => void; } +/** + * A generic retry button component. + */ const RetryButton = ({ retry, ...rest diff --git a/frontend/src/features/navigation/Sheet.tsx b/frontend/src/common/components/Sheet.tsx similarity index 95% rename from frontend/src/features/navigation/Sheet.tsx rename to frontend/src/common/components/Sheet.tsx index 66dda751..f0385206 100644 --- a/frontend/src/features/navigation/Sheet.tsx +++ b/frontend/src/common/components/Sheet.tsx @@ -1,9 +1,9 @@ import BottomSheet from "@gorhom/bottom-sheet"; import React from "react"; -import { type ViewProps, StyleSheet, Dimensions } from "react-native"; +import { Dimensions, StyleSheet, type ViewProps } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Color from "../../common/style/color"; +import Color from "../style/color"; /** * The props for the {@interface Sheet} component. diff --git a/frontend/src/common/components/SkeletonList.tsx b/frontend/src/common/components/SkeletonList.tsx index 0a79917f..e0404839 100644 --- a/frontend/src/common/components/SkeletonList.tsx +++ b/frontend/src/common/components/SkeletonList.tsx @@ -2,36 +2,39 @@ import React from "react"; import { View, type ViewProps } from "react-native"; import Divider from "./Divider"; -import Spacer from "./Spacer"; /** * The props for the {@interface SkeletonList} component. */ -interface SkeletonProps extends ViewProps { +interface SkeletonListProps extends ViewProps { + headerSkeleton?: () => React.ReactNode; /** Returns the skeleton item to render. */ - generator: () => React.ReactNode; + itemSkeleton: () => React.ReactNode; /** Whether to show a divider or a spacer */ - divider: boolean; + divider: "line" | "space"; } /** * A component that renders a list of skeleton items with decreasing opacity. */ const SkeletonList = ({ - generator, - divider, style, -}: SkeletonProps): React.JSX.Element => { + itemSkeleton, + headerSkeleton, + divider, +}: SkeletonListProps): React.JSX.Element => { return ( + {/* Render the header skeleton if it exists */} + {headerSkeleton !== undefined ? headerSkeleton() : null} {/* for (i = 0; i < 3; ++i) */} {Array.from({ length: 3 }).map((_, index) => ( // 1 / 2^i maps to 1.0 opacity, 0.5 opacity, 0.25 opacity, etc. - {generator()} + {itemSkeleton()} {/* Add a divider between each item, except the last one, to be consistent with FlatList */} - {index < 2 ? divider ? : : null} + {index < 2 ? divider === "line" ? : : null} ))} diff --git a/frontend/src/common/components/TextSkeleton.tsx b/frontend/src/common/components/TextSkeleton.tsx index 9060979b..c2fb3bb4 100644 --- a/frontend/src/common/components/TextSkeleton.tsx +++ b/frontend/src/common/components/TextSkeleton.tsx @@ -1,12 +1,16 @@ import React from "react"; -import { Text, StyleSheet, type TextProps, Dimensions } from "react-native"; +import { Dimensions, StyleSheet, Text, type TextProps } from "react-native"; import Color from "../style/color"; interface TextSkeletonProps extends TextProps { + /** The width of the skeleton text as a fraction of the screen width. */ widthFraction: number; } +/** + * A skeleton text element that can be used as a placeholder for real text elements. + */ const TextSkeleton = ({ widthFraction, style, diff --git a/frontend/src/common/hooks.ts b/frontend/src/common/hooks.ts index 663f88e4..d0928411 100644 --- a/frontend/src/common/hooks.ts +++ b/frontend/src/common/hooks.ts @@ -1,7 +1,7 @@ import { - type TypedUseSelectorHook, - useSelector, useDispatch, + useSelector, + type TypedUseSelectorHook, } from "react-redux"; import { type AppDispatch, type RootState } from "../app/store"; diff --git a/frontend/src/common/query.ts b/frontend/src/common/query.ts new file mode 100644 index 00000000..26332fc3 --- /dev/null +++ b/frontend/src/common/query.ts @@ -0,0 +1,147 @@ +import { type FetchBaseQueryError } from "@reduxjs/toolkit/query"; + +/** + * A generic query object that represents the state of a data fetch. Generally follows + * the RTK query result type shape. + */ +export type Query = + | SuccessQuery + | LoadingQuery + | ErrorQuery; + +export interface SuccessQuery { + data: T; + isSuccess: true; + isError: false; + isLoading: false; + error: undefined; +} + +export interface LoadingQuery { + data: undefined; + isSuccess: false; + isLoading: true; + isError: false; + error: undefined; +} + +export interface ErrorQuery { + data: undefined; + isSuccess: false; + isLoading: false; + isError: true; + error: E; +} + +/** + * Create a success query with a given data. + */ +export function success(data: T): SuccessQuery { + return { + data, + isSuccess: true, + isError: false, + isLoading: false, + error: undefined, + }; +} + +const LOADING: LoadingQuery = { + data: undefined, + isSuccess: false, + isLoading: true, + isError: false, + error: undefined, +}; + +/** + * Create a loading query. + */ +export function loading(): LoadingQuery { + return LOADING; +} + +/** + * Create an error query with a given error. + */ +export function error(error: E): ErrorQuery { + return { + data: undefined, + isSuccess: false, + isLoading: false, + isError: true, + error, + }; +} + +/** + * Wrap a RTK Query object in a Query object. + */ +export const wrapReduxQuery = ( + // I didn't want to figure out RTK Query's insane type soup. Just use a big wildcard. + query: Record, +): Query => { + if (query.isSuccess as boolean) { + return success(query.data as T); + } else if (query.isError as boolean) { + const err = query.error; + if (isFetchBaseQueryError(err)) { + const errMsg = "error" in err ? err.error : JSON.stringify(err.data); + return error(errMsg); + } else if (isErrorWithMessage(err)) { + return error(err.message); + } else { + return error(JSON.stringify(err)); + } + } else { + return loading(); + } +}; + +/** + * Type predicate to narrow an unknown error to `FetchBaseQueryError` + */ +function isFetchBaseQueryError(error: unknown): error is FetchBaseQueryError { + return typeof error === "object" && error != null && "status" in error; +} + +/** + * Type predicate to narrow an unknown error to an object with a string 'message' property + */ +function isErrorWithMessage(error: unknown): error is { message: string } { + return ( + typeof error === "object" && + error != null && + "message" in error && + typeof (error as Record).message === "string" + ); +} + +/** + * Map a query's data to a new value. + */ +export const mapQuery = ( + query: Query, + block: (data: T) => U, +): Query => { + if (query.isSuccess) { + return { + ...query, + data: block(query.data), + }; + } + return query; +}; + +/** + * Map a query's data to a new query. + */ +export const deepMapQuery = ( + query: Query, + block: (data: T) => Query, +): Query => { + if (query.isSuccess) { + return block(query.data); + } + return query; +}; diff --git a/frontend/src/common/style/color.ts b/frontend/src/common/style/color.ts index cff5d8e0..2d2839f4 100644 --- a/frontend/src/common/style/color.ts +++ b/frontend/src/common/style/color.ts @@ -1,21 +1,4 @@ export default { - orecart: { - tungsten: "#CC4628", - silver: "#879EC3", - gold: "#F1B91A", - get: (name: string): string | undefined => { - switch (name) { - case "Tungsten": - return "#CC4628"; - case "Silver": - return "#879EC3"; - case "Gold": - return "#F1B91A"; - default: - return undefined; - } - }, - }, csm: { primary: { dark_blue: "#21314D", diff --git a/frontend/src/features/alert/AlertBanner.tsx b/frontend/src/features/alert/AlertBanner.tsx index ece2be99..0447e922 100644 --- a/frontend/src/features/alert/AlertBanner.tsx +++ b/frontend/src/features/alert/AlertBanner.tsx @@ -8,97 +8,110 @@ import { View, } from "react-native"; -import Spacer from "../../common/components/Spacer"; +import ErrorMessage from "../../common/components/ErrorMessage"; +import Divider from "../../common/components/Spacer"; +import { type Query } from "../../common/query"; import Color from "../../common/style/color"; import { AlertItem } from "./AlertItem"; -import { useGetActiveAlertsQuery } from "./alertSlice"; +import { type Alert } from "./alertSlice"; + +export interface AlertBannerProps { + alerts: Query; + refresh: () => Promise; +} /** * A component that renders a collapsible list of active alerts. */ -const AlertBanner = (): React.JSX.Element | null => { - const alerts = useGetActiveAlertsQuery().data; +const AlertBanner = ({ + alerts, + refresh, +}: AlertBannerProps): React.JSX.Element | null => { const [expanded, setExpanded] = useState(false); - if (alerts === undefined || alerts.length === 0) { + if (alerts.isError) { + return ( + + ); + } + + if (alerts.isLoading || alerts.data.length === 0) { return null; } // It would be best to reduce the friction of the user having to expand the component // if there is just one alert, as we have the space to show it in it's entirety // (as we do later on in the component) - const expandable = alerts.length > 1; + const expandable = alerts.data.length > 1; return ( - - { - setExpanded((expanded) => !expanded); - } - : undefined - } - style={styles.container} - > - - - Alert - {/* Indicate the expansion status with an icon, or hide it if we don't need to be expandable. */} - {expandable ? ( - - ) : null} - - {/* */} - {expanded && expandable ? ( - ( - /* Want to put the alert information into a container to differentiate it from others */ - - )} - keyExtractor={(item) => item.id.toString()} - ItemSeparatorComponent={Spacer} + { + setExpanded((expanded) => !expanded); + } + : undefined + } + style={styles.container} + > + + + Alert + {/* Indicate the expansion status with an icon, or hide it if we don't need to be expandable. */} + {expandable ? ( + - ) : /* If there's just one alert, resulting in the component not being expandable, - we would want to show it in full rather than require the user to expand it, given that we have the space. */ - expandable ? ( - {`${alerts.length} currently active`} - ) : ( - - )} + ) : null} - - + {/* */} + {expanded && expandable ? ( + ( + /* Want to put the alert information into a container to differentiate it from others */ + + )} + keyExtractor={(item) => item.id.toString()} + ItemSeparatorComponent={Divider} + /> + ) : /* If there's just one alert, resulting in the component not being expandable, + we would want to show it in full rather than require the user to expand it, given that we have the space. */ + expandable ? ( + {`${alerts.data.length} currently active`} + ) : ( + + )} + + ); }; const styles = StyleSheet.create({ - spacing: { - paddingBottom: 8, - }, container: { - padding: 16, backgroundColor: Color.generic.alert.primary, borderRadius: 16, }, headerContainer: { + paddingHorizontal: 16, + paddingTop: 16, flexDirection: "row", alignItems: "center", justifyContent: "space-between", - marginBottom: 8, }, alertsContainer: { - marginTop: 8, + padding: 16, }, header: { fontSize: 20, @@ -112,6 +125,9 @@ const styles = StyleSheet.create({ }, alertText: { fontSize: 16, + paddingTop: 16, + paddingHorizontal: 16, + paddingBottom: 16, color: Color.generic.white, }, }); diff --git a/frontend/src/features/alert/AlertItem.tsx b/frontend/src/features/alert/AlertItem.tsx index b5cc61cd..8a2823b5 100644 --- a/frontend/src/features/alert/AlertItem.tsx +++ b/frontend/src/features/alert/AlertItem.tsx @@ -1,4 +1,4 @@ -import { View, Text, StyleSheet, type ViewProps } from "react-native"; +import { StyleSheet, Text, View, type ViewProps } from "react-native"; import TextSkeleton from "../../common/components/TextSkeleton"; import Color from "../../common/style/color"; @@ -68,7 +68,7 @@ export const AlertItem = ({ } return ( - + {alert.text} {startTimestamp !== undefined ? ( Starts {startTimestamp} @@ -81,11 +81,9 @@ export const AlertItem = ({ /** * A skeleton component that mimics the {@interface AlertItem} component. */ -export const AlertItemSkeleton = ({ - ...rest -}: ViewProps): React.JSX.Element => { +export const AlertItemSkeleton = (): React.JSX.Element => { return ( - + @@ -94,6 +92,16 @@ export const AlertItemSkeleton = ({ }; const styles = StyleSheet.create({ + containerFull: { + backgroundColor: Color.generic.alert.primary, + borderRadius: 16, + padding: 16, + }, + containerSkeleton: { + padding: 16, + backgroundColor: Color.generic.selection, + borderRadius: 16, + }, alertText: { fontSize: 16, color: Color.generic.white, diff --git a/frontend/src/features/alert/AlertScreen.tsx b/frontend/src/features/alert/AlertScreen.tsx index e6832729..effe1194 100644 --- a/frontend/src/features/alert/AlertScreen.tsx +++ b/frontend/src/features/alert/AlertScreen.tsx @@ -1,76 +1,40 @@ import { type RouteProp } from "@react-navigation/native"; import { type StackNavigationProp } from "@react-navigation/stack"; -import { Text, View, FlatList, StyleSheet, RefreshControl } from "react-native"; +import { StyleSheet } from "react-native"; -import RetryButton from "../../common/components/RetryButton"; -import SkeletonList from "../../common/components/SkeletonList"; -import Spacer from "../../common/components/Spacer"; +import List from "../../common/components/List"; import { type OuterParamList } from "../../common/navTypes"; -import Color from "../../common/style/color"; +import { wrapReduxQuery } from "../../common/query"; import { AlertItem, AlertItemSkeleton } from "./AlertItem"; -import { useGetFutureAlertsQuery } from "./alertSlice"; +import { useGetFutureAlertsQuery, type Alert } from "./alertSlice"; export interface AlertsScreenProps { navigation: StackNavigationProp; route: RouteProp; } +/** + * A screen that displays all future alerts. + */ export const AlertScreen = ({ route, navigation, }: AlertsScreenProps): React.JSX.Element => { - const { - data: alerts, - isLoading, - isSuccess, - isError, - refetch, - } = useGetFutureAlertsQuery(); - - function retry(): void { - refetch().catch(console.error); - } + const alerts = useGetFutureAlertsQuery(); return ( - - {isLoading ? ( - ( - - - - )} - /> - ) : isSuccess ? ( - item.id.toString()} - ItemSeparatorComponent={Spacer} - renderItem={({ item }) => ( - - - - )} - refreshControl={ - // We only want to indicate refreshing when prior data is available. - // Otherwise, the skeleton list will indicate loading - - } - /> - ) : isError ? ( - <> - - We couldn't fetch the alerts right now. Try again later. - - - - ) : null} - + } + itemSkeleton={() => } + divider="space" + query={wrapReduxQuery(alerts)} + refresh={alerts.refetch} + keyExtractor={(alert: Alert) => alert.id.toString()} + bottomSheet={false} + errorMessage="Failed to load alerts. Please try again." + /> ); }; @@ -79,18 +43,4 @@ const styles = StyleSheet.create({ flex: 1, padding: 16, }, - alertItemSkeleton: { - padding: 16, - backgroundColor: Color.generic.selection, - borderRadius: 16, - }, - alertItem: { - backgroundColor: Color.generic.alert.primary, - borderRadius: 16, - padding: 16, - }, - header: { - textAlign: "center", - paddingBottom: 16, - }, }); diff --git a/frontend/src/features/alert/alertSlice.ts b/frontend/src/features/alert/alertSlice.ts index e298a917..9e6833a7 100644 --- a/frontend/src/features/alert/alertSlice.ts +++ b/frontend/src/features/alert/alertSlice.ts @@ -36,8 +36,5 @@ const alertsApiSlice = apiSlice.injectEndpoints({ }), }); -/** - * Hook for querying the list of active alerts. - */ export const { useGetActiveAlertsQuery, useGetFutureAlertsQuery } = alertsApiSlice; diff --git a/frontend/src/features/landing/LandingScreen.tsx b/frontend/src/features/landing/LandingScreen.tsx index a4bbcf98..b0ee83f5 100644 --- a/frontend/src/features/landing/LandingScreen.tsx +++ b/frontend/src/features/landing/LandingScreen.tsx @@ -2,59 +2,72 @@ import { type RouteProp } from "@react-navigation/native"; import { type StackNavigationProp } from "@react-navigation/stack"; import { StyleSheet, View } from "react-native"; -import ErrorMessage from "../../common/components/ErrorMessage"; +import List from "../../common/components/List"; import { type InnerParamList } from "../../common/navTypes"; +import { wrapReduxQuery } from "../../common/query"; import AlertBanner from "../alert/AlertBanner"; +import { useGetActiveAlertsQuery, type Alert } from "../alert/alertSlice"; import LocationPermissionPrompt from "../location/LocationPermissionPrompt"; -import RouteList from "../routes/RouteList"; -import { useGetRoutesQuery } from "../routes/routesSlice"; +import { changeMapFocus } from "../map/mapSlice"; +import { useGetRoutesQuery, type ParentRoute } from "../routes/routesSlice"; + +import { RouteItem, RouteItemSkeleton } from "./RouteItem"; export interface LandingScreenProps { navigation: StackNavigationProp; route: RouteProp; } +/** + * The landing screen shown within the bottom sheet component. Will unfocus the map. + */ export const LandingScreen = ({ route, navigation, }: LandingScreenProps): React.JSX.Element => { - const { data: routes, isError, refetch } = useGetRoutesQuery(); - - function retry(): void { - refetch().catch(console.error); - } + const routes = useGetRoutesQuery(); + const alerts = useGetActiveAlertsQuery(); + changeMapFocus({ + type: "None", + }); return ( - - {isError ? ( - { - retry(); - }} - /> - ) : ( - { + ( + + (alerts)} + refresh={alerts.refetch} + /> + + + )} + item={(route: ParentRoute) => ( + { navigation.push("Route", { routeId: route.id }); }} - defaultHeader={() => ( - - - - - )} /> )} - + itemSkeleton={() => } + divider="line" + query={wrapReduxQuery(routes)} + refresh={async () => await routes.refetch().then(alerts.refetch)} + keyExtractor={(route: ParentRoute) => route.id.toString()} + bottomSheet={true} + errorMessage="Failed to load routes. Please try again." + /> ); }; const styles = StyleSheet.create({ - message: { - margin: 16, + container: { + paddingHorizontal: 8, + }, + header: { + marginBottom: 8, + gap: 8, }, }); diff --git a/frontend/src/features/landing/RouteItem.tsx b/frontend/src/features/landing/RouteItem.tsx new file mode 100644 index 00000000..a7236ad6 --- /dev/null +++ b/frontend/src/features/landing/RouteItem.tsx @@ -0,0 +1,138 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import React from "react"; +import { + StyleSheet, + Text, + TouchableHighlight, + View, + type ViewProps, +} from "react-native"; + +import QueryText from "../../common/components/QueryText"; +import TextSkeleton from "../../common/components/TextSkeleton"; +import { mapQuery, type Query } from "../../common/query"; +import Color from "../../common/style/color"; +import { useClosest, type Closest } from "../location/locationSlice"; +import { + formatMiles, + formatSecondsAsMinutes, + geoDistanceToMiles, +} from "../location/util"; +import { type ParentRoute } from "../routes/routesSlice"; +import { type Stop } from "../stops/stopsSlice"; +import { useArrivalEstimateQuery } from "../vans/arrivalSlice"; + +interface RouteItemProps { + /** The route to display. */ + route: ParentRoute; + /** Called when the route item is clicked on. */ + onPress: (route: ParentRoute) => void; +} +/** + * A component that renders a single route item. + */ +export const RouteItem = ({ + route, + onPress, +}: RouteItemProps): React.JSX.Element => { + const routeNameColorStyle = { + color: route.color, + }; + const closestStop: Query> = useClosest(route.stops); + const stop = mapQuery(closestStop, (closestStop) => closestStop.value); + const arrivalEstimate: Query = useArrivalEstimateQuery( + stop, + route, + ); + + return ( + { + onPress(route); + }} + underlayColor={Color.generic.selection} + style={styles.touchableContainer} + > + + + + {route.name} + + ) => ( + + Closest stop at{" "} + {closestStop.value.name} ( + {formatMiles(geoDistanceToMiles(closestStop.distance))} away) + + )} + skeletonWidth={0.5} + /> + + arrivalEstimate !== undefined ? ( + + Next OreCart in{" "} + + {formatSecondsAsMinutes(arrivalEstimate)} + + + ) : route.isActive ? ( + Running + ) : ( + Not running + ) + } + skeletonWidth={0.6} + error={"Failed to load time estimate"} + /> + + + + + ); +}; + +/** + * A skeleton component that mimics the {@interface RouteItem} component. + */ +export const RouteItemSkeleton = ({ style }: ViewProps): React.JSX.Element => { + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + touchableContainer: { + borderRadius: 16, + }, + innerContainer: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 16, + }, + routeInfoContainer: { + flex: 1, + gap: 4, + }, + routeName: { + fontSize: 24, + fontWeight: "bold", + }, + emphasis: { + fontWeight: "bold", + }, +}); diff --git a/frontend/src/features/location/LocationPermissionPrompt.tsx b/frontend/src/features/location/LocationPermissionPrompt.tsx index 0801205c..6a9c3bf6 100644 --- a/frontend/src/features/location/LocationPermissionPrompt.tsx +++ b/frontend/src/features/location/LocationPermissionPrompt.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { View, Text, TouchableHighlight, StyleSheet } from "react-native"; +import { StyleSheet, Text, TouchableHighlight, View } from "react-native"; import { useAppDispatch } from "../../common/hooks"; import Color from "../../common/style/color"; import { requestLocationPermissions } from "./locationMiddleware"; -import { useLocationStatus } from "./locationSlice"; +import { useLocation } from "./locationSlice"; /** * A banner component that requests the user to grant location permissions to @@ -13,13 +13,13 @@ import { useLocationStatus } from "./locationSlice"; */ const LocationPermissionPrompt = (): React.JSX.Element | null => { const dispatch = useAppDispatch(); - const locationStatus = useLocationStatus(); + const location = useLocation(); const handleGrantButtonPress = (): void => { dispatch(requestLocationPermissions()); }; - if (locationStatus.type !== "not_granted") { + if (location.error !== "Not granted") { return null; } @@ -46,8 +46,6 @@ const styles = StyleSheet.create({ backgroundColor: Color.csm.primary.pale_blue, padding: 16, borderRadius: 16, - marginHorizontal: 16, - marginBottom: 16, }, message: { fontSize: 16, diff --git a/frontend/src/features/location/locationMiddleware.ts b/frontend/src/features/location/locationMiddleware.ts index b6b4f14c..a6699ec8 100644 --- a/frontend/src/features/location/locationMiddleware.ts +++ b/frontend/src/features/location/locationMiddleware.ts @@ -1,8 +1,9 @@ -import { createListenerMiddleware, createAction } from "@reduxjs/toolkit"; +import { createAction, createListenerMiddleware } from "@reduxjs/toolkit"; import * as Location from "expo-location"; import { useEffect } from "react"; import { useAppDispatch } from "../../common/hooks"; +import { error, loading, success } from "../../common/query"; import { updateLocationStatus } from "./locationSlice"; @@ -20,8 +21,9 @@ const ACCURACY = Location.Accuracy.BestForNavigation; let locationSubscription: Location.LocationSubscription | null = null; // The problem with location updates is that they are a callback-based API with sensitive lifecycle limitations -// that we need to cram into the redux API. This middleware attempts to abstract this process with the following -// lifecycle: +// that we need to cram into the redux API. This could be done simpler with a manager hook like in other parts of +// the app, but handling something like location permissions would then become impossible. This middleware attempts +// to abstract this process with the following lifecycle: // 1. The top-level Main component calls manageLocationMiddleware(), which then sends a subscribe action to // the middleware. This creates the subscription and forwards them to the state in the location slice. // 2. The top-level Main component unmounts, which sends an unsubscribe action to the middleware. The middleware @@ -46,11 +48,11 @@ locationMiddleware.startListening({ if (status !== "granted") { // Permission was not granted, we can't do anything. Send the outcome // to the companion slice state. - listenerApi.dispatch(updateLocationStatus({ type: "not_granted" })); + listenerApi.dispatch(updateLocationStatus(error("Not granted"))); return; } - listenerApi.dispatch(updateLocationStatus({ type: "initializing" })); + listenerApi.dispatch(updateLocationStatus(loading())); try { // Have to track the current subscription so we can unsubscribe later. @@ -60,15 +62,19 @@ locationMiddleware.startListening({ // Forward updates to the companion slice so that components can // use the current state. listenerApi.dispatch( - updateLocationStatus({ - type: "active", - location: newLocation.coords, - }), + updateLocationStatus( + success({ + latitude: newLocation.coords.latitude, + longitude: newLocation.coords.longitude, + }), + ), ); }, ); } catch (e) { - listenerApi.dispatch(updateLocationStatus({ type: "error" })); + listenerApi.dispatch( + updateLocationStatus(error("Failed to start location tracking.")), + ); } }, }); @@ -86,7 +92,7 @@ locationMiddleware.startListening({ locationSubscription.remove(); locationSubscription = null; - listenerApi.dispatch(updateLocationStatus({ type: "inactive" })); + listenerApi.dispatch(updateLocationStatus(loading())); }, }); diff --git a/frontend/src/features/location/locationSlice.ts b/frontend/src/features/location/locationSlice.ts index 71328f20..e7a5dd17 100644 --- a/frontend/src/features/location/locationSlice.ts +++ b/frontend/src/features/location/locationSlice.ts @@ -1,6 +1,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; import { useAppSelector } from "../../common/hooks"; +import { error, loading, success, type Query } from "../../common/query"; /** * Implementation of latitude/longitude-based coordinates independent from any particular @@ -11,58 +12,12 @@ export interface Coordinate { longitude: number; } -/** - * The status of location updates. - */ -export type LocationStatus = - | Active - | Inactive - | Initializing - | NotGranted - | Error; - -/** - * Location updates are currently being sent. - */ -export interface Active { - type: "active"; - location: Coordinate; -} - -/** - * Location updates are being initialized. - */ -export interface Initializing { - type: "initializing"; -} - -/** - * Location updates are not currently being sent. - */ -export interface Inactive { - type: "inactive"; -} - -/** - * The user has not granted location permissions yet. - */ -export interface NotGranted { - type: "not_granted"; -} - -/** - * An error occurred while trying to get location updates. - */ -export interface Error { - type: "error"; -} - interface LocationState { - status: LocationStatus; + status: Query; } const initialState: LocationState = { - status: { type: "inactive" }, + status: loading(), }; /* @@ -77,7 +32,7 @@ const locationSlice = createSlice({ name: "location", initialState, reducers: { - updateLocationStatus: (state, action: PayloadAction) => { + updateLocationStatus: (state, action: PayloadAction>) => { state.status = action.payload; }, }, @@ -87,18 +42,96 @@ const locationSlice = createSlice({ * Hook for querying the current location. Will be undefined if not currently * available. */ -export const useLocation = (): Coordinate | undefined => - useAppSelector((state) => - state.location.status.type === "active" - ? state.location.status.location - : undefined, +export const useLocation = (): Query => + useAppSelector((state) => state.location.status); +export interface Closest { + value: T; + distance: number; +} + +/** + * Use the closest coordinate from a list of objects that are coordinates. Will return + * an error if the location is not available, or if there is no data given. + */ +export const useClosest = ( + of: T[], +): Query> => { + const location = useLocation(); + if (!location.isSuccess) { + return location; + } + + const closest = of.reduce | undefined>( + (acc: Closest | undefined, cur: T) => { + const distance = Math.sqrt( + Math.pow(cur.latitude - location.data.latitude, 2) + + Math.pow(cur.longitude - location.data.longitude, 2), + ); + if (acc === undefined || distance < acc.distance) { + return { value: cur, distance }; + } + return acc; + }, + undefined, ); + if (closest === undefined) { + return error("No closest found"); + } + + return success(closest); +}; + +export const useClosestQuery = ( + of: Query, +): Query> => { + const location = useLocation(); + if (!location.isSuccess) { + return location; + } + + if (!of.isSuccess) { + return of; + } + + const closest = of.data.reduce | undefined>( + (acc: Closest | undefined, cur: T) => { + const distance = Math.sqrt( + Math.pow(cur.latitude - location.data.latitude, 2) + + Math.pow(cur.longitude - location.data.longitude, 2), + ); + if (acc === undefined || distance < acc.distance) { + return { value: cur, distance }; + } + return acc; + }, + undefined, + ); + + if (closest === undefined) { + return error("No closest found"); + } + + return success(closest); +}; + /** - * Hook for querying the current location status. + * Use the distance of the coordinate-line object from the current location. Will return + * an error if the location is not available. */ -export const useLocationStatus = (): LocationStatus => - useAppSelector((state) => state.location.status); +export const useDistance = (at: T): Query => { + const location = useLocation(); + if (!location.isSuccess) { + return location; + } + + const distance = Math.sqrt( + Math.pow(at.latitude - location.data.latitude, 2) + + Math.pow(at.longitude - location.data.longitude, 2), + ); + + return success(distance); +}; export const { updateLocationStatus } = locationSlice.actions; diff --git a/frontend/src/features/location/util.ts b/frontend/src/features/location/util.ts index 3af5c126..32a1c77a 100644 --- a/frontend/src/features/location/util.ts +++ b/frontend/src/features/location/util.ts @@ -75,6 +75,11 @@ export function formatMiles(distance: number): string { } } +/** + * Formats a seconds time in human-readable format. + * @param seconds The time in seconds. + * @returns The time in human-readable format. + */ export const formatSecondsAsMinutes = (seconds: number): string => { if (seconds < 60) { return `<1 min`; diff --git a/frontend/src/features/map/Map.tsx b/frontend/src/features/map/Map.tsx index 0399ce56..f05252dc 100644 --- a/frontend/src/features/map/Map.tsx +++ b/frontend/src/features/map/Map.tsx @@ -1,10 +1,10 @@ import { MaterialIcons } from "@expo/vector-icons"; -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Image, StyleSheet, View, type ViewProps } from "react-native"; import MapView, { Marker, - PROVIDER_GOOGLE, Polyline, + PROVIDER_GOOGLE, type Region, } from "react-native-maps"; @@ -12,11 +12,20 @@ import FloatingButton from "../../common/components/FloatingButton"; import Color from "../../common/style/color"; import LayoutStyle from "../../common/style/layout"; import SpacingStyle, { type Insets } from "../../common/style/spacing"; -import { useLocationStatus, type Coordinate } from "../location/locationSlice"; -import { useGetRoutesQuery, type ExtendedRoute } from "../routes/routesSlice"; -import { useGetStopsQuery } from "../stops/stopsSlice"; -import { useGetVansQuery } from "../vans/vansSlice"; +import { useLocation, type Coordinate } from "../location/locationSlice"; +import { + useGetRoutesQuery, + type Route, + type WaypointRoute, +} from "../routes/routesSlice"; +import { + useGetStopsQuery, + type ColorStop, + type Stop, +} from "../stops/stopsSlice"; +import { useVanLocations } from "../vans/locations"; +import { useMapFocus } from "./mapSlice"; import Pie from "./Pie"; const GOLDEN: Region = { @@ -35,18 +44,24 @@ interface MapProps extends ViewProps { * obscured. Note that status bar insets will already be applied, so don't include those. */ insets?: Insets; + onStopPressed: (stop: Stop) => void; } /** * A wrapper around react native {@class MapView} that provides a simplified interface for the purposes of this app. */ -const Map = ({ insets }: MapProps): React.JSX.Element => { +const Map = ({ insets, onStopPressed }: MapProps): React.JSX.Element => { const mapRef = useRef(null); const [followingLocation, setFollowingLocation] = useState(true); - const [lastLocation, setLastLocation] = React.useState< - Coordinate | undefined - >(undefined); - const locationStatus = useLocationStatus(); + const [lastLocation, setLastLocation] = useState( + undefined, + ); + + const focus = useMapFocus(); + const location = useLocation(); + const { data: routes } = useGetRoutesQuery(); + const { data: stops } = useGetStopsQuery(); + const { data: vans } = useVanLocations(); function panToLocation(location: Coordinate | undefined): void { if (location !== undefined && mapRef.current != null) { @@ -62,7 +77,7 @@ const Map = ({ insets }: MapProps): React.JSX.Element => { // us to pan the camera to whatever location we were last given. Always track // this regardless of if following is currently on. setLastLocation(location); - if (followingLocation) { + if (followingLocation && focus.type === "None") { panToLocation(location); } } @@ -79,6 +94,83 @@ const Map = ({ insets }: MapProps): React.JSX.Element => { }); } + /** + * Given a function of waypoints, return a region from the most top-left to the most bottom-right + * points in the list. + */ + function routeBounds(route: WaypointRoute): Region { + // Find the min and max latitude and longitude values to create a bounding box for the route. + let minLat = route.waypoints[0].latitude; + let maxLat = route.waypoints[0].latitude; + let minLon = route.waypoints[0].longitude; + let maxLon = route.waypoints[0].longitude; + for (let i = 1; i < route.waypoints.length; i++) { + const waypoint = route.waypoints[i]; + if (waypoint.latitude < minLat) minLat = waypoint.latitude; + if (waypoint.latitude > maxLat) maxLat = waypoint.latitude; + if (waypoint.longitude < minLon) minLon = waypoint.longitude; + if (waypoint.longitude > maxLon) maxLon = waypoint.longitude; + } + // Add some additional padding to the bounds to ensure that the route is fully visible. + minLat -= 0.0025; + maxLat += 0.0025; + minLon -= 0.0025; + maxLon += 0.0025; + return { + latitude: (minLat + maxLat) / 2, + latitudeDelta: maxLat - minLat, + longitude: (minLon + maxLon) / 2, + longitudeDelta: maxLon - minLon, + }; + } + + function stopBounds(stop: Stop): Region { + return { + latitude: stop.latitude, + latitudeDelta: 0.0025, + longitude: stop.longitude, + longitudeDelta: 0.0025, + }; + } + + useEffect(() => { + if (focus.type === "None") { + // Return to the user location if enabled. + if (followingLocation && location.isSuccess) { + panToLocation(location.data); + } + // Do nothing if we are following location and haven't gotten a location yet. + // We could maybe backtrack to the previous region before the navigation, but + // the callbacks/rerenders required kills performance. + } else if (focus.type === "SingleRoute") { + mapRef.current?.animateToRegion(routeBounds(focus.route)); + } else if (focus.type === "SingleStop") { + mapRef.current?.animateToRegion(stopBounds(focus.stop)); + } + }, [focus]); + + function isStopVisible(stop: ColorStop): boolean { + // Either on the focused route or being focused on itself + if (focus.type === "SingleRoute") { + return focus.route.stops.some((other) => stop.id === other.id); + } + if (focus.type === "SingleStop") { + return stop.id === focus.stop.id; + } + return true; + } + + function isRouteVisible(route: Route): boolean { + // Either on the focused stop or being focused on itself + if (focus.type === "SingleRoute") { + return route.id === focus.route.id; + } + if (focus.type === "SingleStop") { + return focus.stop.routes.some((other) => route.id === other.id); + } + return true; + } + const padding = { top: insets?.top ?? 0, right: insets?.right ?? 0, @@ -86,33 +178,27 @@ const Map = ({ insets }: MapProps): React.JSX.Element => { left: insets?.left ?? 0, }; - const { data: vans } = useGetVansQuery(); - const { data: routes } = useGetRoutesQuery(); - const { data: stops } = useGetStopsQuery(); - - const routesById: Record = {}; - routes?.forEach((route) => { - routesById[route.id] = route; - }); - return ( { // Let's say the user accidentally pans a tad before they realize // that they haven't granted location permissions. We won't pan // back to their location until they re-toggle the location button. // That's not very good UX. - if (locationStatus.type !== "not_granted") { + if (location.isSuccess && focus.type === "None") { setFollowingLocation(false); } }} @@ -120,45 +206,33 @@ const Map = ({ insets }: MapProps): React.JSX.Element => { updateLocation(event.nativeEvent.coordinate); }} > - {vans?.map((van, index) => - van.location !== undefined ? ( - - - - - - ) : null, - )} - {routes?.map((route, index) => ( + {routes?.map((route) => ( ))} - {stops?.map((stop, index) => ( - + {stops?.map((stop) => ( + { + e.preventDefault(); + onStopPressed(stop); + }} + zIndex={2 + (isStopVisible(stop) ? 1 : 0)} + coordinate={stop} + // opacity={isStopVisible(stop) ? 1 : 0.25} + tracksViewChanges={false} + anchor={{ x: 0.5, y: 0.5 }} + > {/* Create a similar border to that of the van indicators, but segment it @@ -168,13 +242,7 @@ const Map = ({ insets }: MapProps): React.JSX.Element => { a border. */} - - Color.orecart.get(routesById[routeId]?.name) ?? - Color.generic.black, - )} - /> + { ))} + {vans?.map((van) => ( + + + + + + ))} {/* Layer the location button on the map instead of displacing it. */} { styles.locationButtonContainer, ]} > - {locationStatus.type !== "not_granted" ? ( + {location.isSuccess && focus.type === "None" ? ( { flipFollowingLocation(); diff --git a/frontend/src/features/map/mapSlice.ts b/frontend/src/features/map/mapSlice.ts new file mode 100644 index 00000000..fea6ec3f --- /dev/null +++ b/frontend/src/features/map/mapSlice.ts @@ -0,0 +1,77 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; + +import { useAppDispatch, useAppSelector } from "../../common/hooks"; +import { type ParentRoute } from "../routes/routesSlice"; +import { type ParentStop } from "../stops/stopsSlice"; + +/** + * The current element the map is focused on. + */ +export type MapFocus = None | SingleRoute | SingleStop; + +/** + * The map is not focused on anything. + */ +export interface None { + type: "None"; +} + +/** + * The map is focused on a route. + */ +export interface SingleRoute { + type: "SingleRoute"; + route: ParentRoute; +} + +/** + * The map is focused on a stop. + */ +export interface SingleStop { + type: "SingleStop"; + stop: ParentStop; +} + +interface MapState { + focus: MapFocus; +} + +const initialState: MapState = { + focus: { type: "None" }, +}; + +/** + * Controls the current map focus. Should not be used by any component. + */ +export const mapSlice = createSlice({ + name: "map", + initialState, + reducers: { + focusMap: (state, action: PayloadAction) => { + state.focus = action.payload; + }, + }, +}); + +const { focusMap } = mapSlice.actions; + +/** + * Causes the map to pan to the given focus. Note that this only works in + * screens within a navigator due to the use of the `useFocusEffect` hook. + */ +export const changeMapFocus = (focus?: MapFocus): void => { + const dispatch = useAppDispatch(); + useFocusEffect(() => { + if (focus !== undefined) { + dispatch(focusMap(focus)); + } + }); +}; + +/** + * Hook for querying the current map focus. + */ +export const useMapFocus = (): MapFocus => { + return useAppSelector((state) => state.map.focus); +}; diff --git a/frontend/src/features/routes/RouteItem.tsx b/frontend/src/features/routes/RouteItem.tsx deleted file mode 100644 index 4c1cb3ea..00000000 --- a/frontend/src/features/routes/RouteItem.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { MaterialIcons } from "@expo/vector-icons"; -import React from "react"; -import { - StyleSheet, - Text, - TouchableHighlight, - View, - type ViewProps, -} from "react-native"; - -import TextSkeleton from "../../common/components/TextSkeleton"; -import Color from "../../common/style/color"; -import { useLocation } from "../location/locationSlice"; -import { - closest, - distance, - formatMiles, - formatSecondsAsMinutes, - geoDistanceToMiles, -} from "../location/util"; -import { - useGetStopsQuery, - type BasicStop, - type ExtendedStop, -} from "../stops/stopsSlice"; -import { useGetVansQuery, type VanLocation } from "../vans/vansSlice"; - -import { type BasicRoute, type ExtendedRoute } from "./routesSlice"; - -interface RouteItemProps { - mode: "basic" | "extended"; - route: ExtendedRoute; - inStop?: BasicStop; - onPress: (route: ExtendedRoute) => void; -} - -/** - * A component that renders a single route item. - */ -export const RouteItem = ({ - mode, - route, - inStop, - onPress, -}: RouteItemProps): React.JSX.Element => { - const closestStop = useClosestStop(route, inStop); - const routeNameColorStyle = { - color: Color.orecart.get(route.name) ?? Color.generic.black, - }; - - // TODO: Remove as soon as we fetch colors from backend - - return ( - { - onPress(route); - }} - underlayColor={Color.generic.selection} - style={styles.touchableContainer} - > - - - - {route.name} - - {route.isActive ? ( - closestStop !== undefined ? ( - <> - - Next OreCart in{" "} - - {closestStop.vanArrivalTime} - - - {mode === "extended" ? ( - - At {closestStop.name} ({closestStop.distanceFromUser}) - - ) : null} - - ) : ( - Running - ) - ) : ( - Not running - )} - - - - - ); -}; - -interface ClosestStop extends ExtendedStop { - distanceFromUser: string; - vanArrivalTime: string; -} - -function useClosestStop( - to: BasicRoute, - inStop?: ExtendedStop, -): ClosestStop | undefined { - const vans = useGetVansQuery().data; - const location = useLocation(); - const stops = useGetStopsQuery().data; - if (vans === undefined) { - return undefined; - } - if (location === undefined) { - return undefined; - } - - let stop = inStop; - if (stop === undefined) { - if (stops === undefined) { - return undefined; - } - - const routeStops = stops.filter((stop) => stop.routeIds.includes(to.id)); - const closestRouteStop = closest(routeStops, location); - if (closestRouteStop === undefined) { - return undefined; - } - stop = closestRouteStop.inner; - } - - const arrivingVans = vans - .filter( - (van) => - van.location !== undefined && - van.location.nextStopId === stop.id && - van.routeId === to.id, - ) - .map((van) => van.location) as VanLocation[]; - const closestRouteStopVan = closest(arrivingVans, location); - if (closestRouteStopVan === undefined) { - return undefined; - } - - return { - ...stop, - distanceFromUser: formatMiles(geoDistanceToMiles(distance(stop, location))), - vanArrivalTime: formatSecondsAsMinutes( - closestRouteStopVan.inner.secondsToNextStop, - ), - }; -} - -/** - * A skeleton component that mimics the {@interface RouteItem} component. - */ -export const RouteItemSkeleton = ({ style }: ViewProps): React.JSX.Element => { - return ( - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - touchableContainer: { - borderRadius: 16, - }, - innerContainer: { - flexDirection: "row", - alignItems: "center", - paddingVertical: 16, - paddingHorizontal: 16, - }, - routeInfoContainer: { - flex: 1, - }, - routeName: { - fontSize: 24, - fontWeight: "bold", - }, - routeStatus: { - marginVertical: 4, - }, - routeStatusEmphasis: { - fontWeight: "bold", - }, - routeContext: { - fontSize: 12, - }, -}); diff --git a/frontend/src/features/routes/RouteList.tsx b/frontend/src/features/routes/RouteList.tsx deleted file mode 100644 index a7e391cc..00000000 --- a/frontend/src/features/routes/RouteList.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { BottomSheetSectionList } from "@gorhom/bottom-sheet"; -import { useFocusEffect } from "@react-navigation/native"; -import React from "react"; -import { StyleSheet, View } from "react-native"; - -import Divider from "../../common/components/Divider"; -import SkeletonList from "../../common/components/SkeletonList"; -import LayoutStyle from "../../common/style/layout"; -import { type BasicStop } from "../stops/stopsSlice"; - -import { RouteItem, RouteItemSkeleton } from "./RouteItem"; -import { type ExtendedRoute } from "./routesSlice"; - -interface RouteListProps { - mode: "basic" | "extended"; - routes: ExtendedRoute[] | undefined; - inStop?: BasicStop; - defaultHeader?: () => React.JSX.Element; - renderStop?: (route: BasicStop) => React.JSX.Element; - renderStopSkeleton?: () => React.JSX.Element; - onPress: (route: ExtendedRoute) => void; -} - -/** - * Screen that displays a list of routes. - */ -const RouteList = ({ - mode, - routes, - inStop, - defaultHeader, - renderStop, - renderStopSkeleton, - onPress, -}: RouteListProps): React.JSX.Element => { - return ( - - {routes === undefined ? ( - } - /> - ) : ( - ( - - )} - renderSectionHeader={({ section: { stop } }) => { - if (renderStop === undefined || renderStopSkeleton === undefined) { - return defaultHeader !== undefined ? defaultHeader() : null; - } - return stop !== undefined ? renderStop(stop) : renderStopSkeleton(); - }} - keyExtractor={(item) => item.id.toString()} - ItemSeparatorComponent={Divider} - /> - )} - - ); -}; - -const styles = StyleSheet.create({ - routeContainer: { - paddingHorizontal: 8, - }, - loadingContainer: { - padding: 16, - }, - header: { - textAlign: "center", - paddingBottom: 16, - }, -}); - -export default RouteList; diff --git a/frontend/src/features/routes/RouteScreen.tsx b/frontend/src/features/routes/RouteScreen.tsx index 5c46b457..f30fe299 100644 --- a/frontend/src/features/routes/RouteScreen.tsx +++ b/frontend/src/features/routes/RouteScreen.tsx @@ -3,112 +3,133 @@ import { type StackNavigationProp } from "@react-navigation/stack"; import React from "react"; import { StyleSheet, Text, View } from "react-native"; -import ErrorMessage from "../../common/components/ErrorMessage"; +import ParentChildList from "../../common/components/ParentChildList"; import TextSkeleton from "../../common/components/TextSkeleton"; import { type InnerParamList } from "../../common/navTypes"; +import { mapQuery, wrapReduxQuery } from "../../common/query"; import Color from "../../common/style/color"; -import StopList from "../stops/StopList"; -import { useGetStopsQuery } from "../stops/stopsSlice"; +import { useClosest } from "../location/locationSlice"; +import { changeMapFocus, type MapFocus } from "../map/mapSlice"; +import { type Stop } from "../stops/stopsSlice"; -import { useGetRouteQuery, type BasicRoute } from "./routesSlice"; +import { useGetRouteQuery, type ParentRoute } from "./routesSlice"; +import { RouteStopItem, RouteStopItemSkeleton } from "./RouteStopItem"; export interface RouteScreenProps { navigation: StackNavigationProp; route: RouteProp; } +/** + * Shows route information and stops. Will refocus the map onto the given route. + */ export const RouteScreen = ({ route: navRoute, navigation, }: RouteScreenProps): React.JSX.Element => { - const { routeId } = navRoute.params; - const { - data: route, - isError: routeError, - refetch: refetchRoute, - } = useGetRouteQuery(routeId); - const { - data: stops, - isError: stopsError, - refetch: refetchStops, - } = useGetStopsQuery(); - - const routeStops = stops?.filter((stop) => stop.routeIds.includes(routeId)); - - function retryRoute(): void { - refetchRoute().catch(console.error); - } - - function retryStops(): void { - refetchStops().catch(console.error); - } + const route = useGetRouteQuery(navRoute.params.routeId); + const routeFocus: MapFocus | undefined = route.isSuccess + ? { type: "SingleRoute", route: route.data } + : undefined; + changeMapFocus(routeFocus); return ( - - {stopsError || routeError ? ( - { - retryRoute(); - retryStops(); + ( + { + navigation.push("Stop", { stopId: stop.id }); }} /> - ) : ( - } - renderRouteSkeleton={() => } - onPress={(stop) => { + )} + headerSkeleton={() => } + item={(route: ParentRoute, stop: Stop) => ( + { navigation.push("Stop", { stopId: stop.id }); }} + invert={false} /> )} - + itemSkeleton={() => } + divider="line" + query={wrapReduxQuery(route)} + refresh={route.refetch} + map={(route: ParentRoute) => route.stops} + keyExtractor={(stop: Stop) => stop.id.toString()} + bottomSheet={true} + errorMessage="Failed to load route. Please try again." + /> ); }; -const RouteHeader = ({ route }: { route: BasicRoute }): React.JSX.Element => { - const routeNameColorStyle = { color: Color.orecart.get(route?.name) }; +const RouteHeader = ({ + route, + onClosestStopPress, +}: { + route: ParentRoute; + onClosestStopPress: (stop: Stop) => void; +}): React.JSX.Element => { + const routeNameColorStyle = { color: route.color }; + const closestStop = useClosest(route.stops); + const stop = mapQuery(closestStop, (closestStop) => closestStop.value); return ( - - {route?.name} - {getDescriptionWorkaround(route)} + + + + {route?.name} + + {route.description} + + + Closest Stop + {stop.isSuccess ? ( + + ) : ( + + )} + + Route Path ); }; -const RouteSkeleton = (): React.JSX.Element => { +const RouteHeaderSkeleton = (): React.JSX.Element => { return ( - - - + + + + + + + + + + ); }; -// TODO: REMOVE AS SOON AS POSSIBLE!!!! -const getDescriptionWorkaround = (route: BasicRoute): string | undefined => { - switch (route.name) { - case "Tungsten": - return "Travels between campus and the RTD W Line Stop"; - - case "Silver": - return "Travels between campus and Mines Park"; - - case "Gold": - return "Travels between campus and downtown Golden"; - - default: - return undefined; - } -}; - const styles = StyleSheet.create({ - container: { - padding: 16, + listContainer: { + paddingHorizontal: 8, + }, + headerInfoContainer: { + paddingHorizontal: 16, + paddingTop: 16, + }, + headerSubheadContainer: { + paddingHorizontal: 16, }, routeName: { fontSize: 32, @@ -118,6 +139,30 @@ const styles = StyleSheet.create({ routeDesc: { fontSize: 16, }, + closestStopContainer: { + backgroundColor: Color.csm.primary.blaster_blue, + borderRadius: 16, + marginVertical: 16, + }, + closestStopContainerSkeleton: { + backgroundColor: Color.generic.selection, + borderRadius: 16, + marginVertical: 16, + }, + closestStopHeader: { + fontSize: 20, + fontWeight: "bold", + marginBottom: 8, + marginHorizontal: 16, + marginTop: 16, + color: Color.csm.neutral.white, + }, + routePathHeader: { + fontSize: 20, + fontWeight: "bold", + marginHorizontal: 16, + marginBottom: 8, + }, message: { marginVertical: 16, }, diff --git a/frontend/src/features/routes/RouteStopItem.tsx b/frontend/src/features/routes/RouteStopItem.tsx new file mode 100644 index 00000000..0f44180f --- /dev/null +++ b/frontend/src/features/routes/RouteStopItem.tsx @@ -0,0 +1,161 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import React from "react"; +import { + StyleSheet, + Text, + TouchableHighlight, + View, + type ViewProps, +} from "react-native"; + +import QueryText from "../../common/components/QueryText"; +import TextSkeleton from "../../common/components/TextSkeleton"; +import Color from "../../common/style/color"; +import { useDistance } from "../location/locationSlice"; +import { + formatMiles, + formatSecondsAsMinutes, + geoDistanceToMiles, +} from "../location/util"; +import { type Stop } from "../stops/stopsSlice"; +import { useArrivalEstimate } from "../vans/arrivalSlice"; + +import { type ParentRoute } from "./routesSlice"; + +/** + * The props for the {@interface StopItem} component. + */ +interface RouteStopItemProps extends ViewProps { + /** The stop to display. */ + stop: Stop; + /** The parent route of the stop. The stop should be part of this. */ + route: ParentRoute; + /** Called when the stop item is clicked on. */ + onPress: (stop: Stop) => void; + invert: boolean; +} + +/** + * A component that renders a single stop item. + */ +export const RouteStopItem = ({ + style, + stop, + route, + onPress, + invert, +}: RouteStopItemProps): React.JSX.Element => { + const distance = useDistance(stop); + const arrivalEstimate = useArrivalEstimate(stop, route); + + return ( + { + onPress(stop); + } + : undefined + } + underlayColor={ + invert + ? Color.csm.primary.ext.blaster_blue_highlight + : Color.generic.selection + } + style={[styles.touchableContainer, style]} + > + + + + {stop.name} + + ( + <> + + {formatMiles(geoDistanceToMiles(distance))} + {" "} + away + + )} + skeletonWidth={0.3} + /> + + arrivalEstimate !== undefined ? ( + <> + Next OreCart in{" "} + + {formatSecondsAsMinutes(arrivalEstimate)} + + + ) : route.isActive ? ( + <>Running + ) : ( + <>Not running + ) + } + skeletonWidth={0.5} + error={"Failed to load time estimate"} + /> + + + + + ); +}; + +/** + * A skeleton component that mimics the {@interface StopItem} component. + */ +export const RouteStopItemSkeleton = ({ + style, +}: ViewProps): React.JSX.Element => { + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + touchableContainer: { + borderRadius: 16, + }, + highlightedTouchableContainer: { + borderRadius: 16, + backgroundColor: Color.csm.primary.blaster_blue, + }, + innerContainer: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 16, + }, + stopInfoContainer: { + flex: 1, + gap: 4, + }, + stopName: { + fontSize: 24, + fontWeight: "bold", + }, + emphasis: { + fontWeight: "bold", + }, + invert: { + color: Color.generic.white, + }, +}); diff --git a/frontend/src/features/routes/routesSlice.ts b/frontend/src/features/routes/routesSlice.ts index afee6215..3e95cc80 100644 --- a/frontend/src/features/routes/routesSlice.ts +++ b/frontend/src/features/routes/routesSlice.ts @@ -1,23 +1,37 @@ import apiSlice from "../../app/apiSlice"; import { type Coordinate } from "../location/locationSlice"; +import { type Stop } from "../stops/stopsSlice"; /** - * A list of routes, as defined by the backend. + * Minimum information sent by the server about a route. */ -export type Routes = ExtendedRoute[]; - -export interface BasicRoute { +export interface Route { + /** The id of the route. */ id: number; + /** The name of the route. */ name: string; - stopIds: number[]; + /** Whether the route is currently active or has an outage. */ + isActive: boolean; + /** The color of the route. */ + color: string; } /** - * A Route, as defined by the backend. + * A route with waypoints. */ -export interface ExtendedRoute extends BasicRoute { +export interface WaypointRoute extends Route { + /** The coordinates outlining the path of the route. */ waypoints: Coordinate[]; - isActive: boolean; +} + +/** + * A route with child stops. + */ +export interface ParentRoute extends WaypointRoute { + /** The stops of the route. */ + stops: Stop[]; + /** The description of the route (i.e where it goes). */ + description: string; } // --- API Definition --- @@ -29,13 +43,13 @@ export interface ExtendedRoute extends BasicRoute { const routesApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - getRoutes: builder.query({ - query: () => - "/routes/?include=stopIds&include=waypoints&include=isActive", + getRoutes: builder.query({ + query: () => "/routes/?include=stops&include=waypoints&include=isActive", providesTags: ["Routes"], }), - getRoute: builder.query({ - query: (id) => `/routes/${id}?include=stopIds`, + getRoute: builder.query({ + query: (id) => + `/routes/${id}?include=stops&include=waypoints&include=isActive`, providesTags: (_, __, id) => [{ type: "Route", id }], }), }), diff --git a/frontend/src/features/stops/StopItem.tsx b/frontend/src/features/stops/StopItem.tsx deleted file mode 100644 index a6f6ce52..00000000 --- a/frontend/src/features/stops/StopItem.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { MaterialIcons } from "@expo/vector-icons"; -import React from "react"; -import { - StyleSheet, - Text, - TouchableHighlight, - View, - type ViewProps, -} from "react-native"; - -import TextSkeleton from "../../common/components/TextSkeleton"; -import Color from "../../common/style/color"; -import { useLocation } from "../location/locationSlice"; -import { - closest, - distance, - formatMiles, - formatSecondsAsMinutes, - geoDistanceToMiles, -} from "../location/util"; -import { type BasicRoute } from "../routes/routesSlice"; -import { useGetVansQuery, type VanLocation } from "../vans/vansSlice"; - -import { type BasicStop, type ExtendedStop } from "./stopsSlice"; - -/** - * The props for the {@interface StopItem} component. - */ -interface StopItemProps { - /** The stop to display. */ - stop: BasicStop; - inRoute?: BasicRoute; - /** Called when the stop item is clicked on. */ - onPress: (stop: BasicStop) => void; -} - -/** - * A component that renders a single stop item. - */ -export const StopItem = ({ - stop, - inRoute, - onPress, -}: StopItemProps): React.JSX.Element => { - const stopState = useStopState(stop, inRoute); - - // TODO: Remove as soon as we fetch colors from backend - - return ( - { - onPress(stop); - }} - underlayColor={Color.generic.selection} - style={styles.touchableContainer} - > - - - {stop.name} - {stopState.distanceFromUser !== undefined ? ( - - - {stopState.distanceFromUser} - {" "} - away - - ) : null} - {stopState.vanArrivalTime !== undefined ? ( - - Next OreCart in{" "} - - {stopState.vanArrivalTime} - - - ) : stop.isActive ? ( - Running - ) : ( - Not running - )} - - - - - ); -}; - -interface StopState { - distanceFromUser?: string; - vanArrivalTime?: string; -} - -function useStopState(stop: ExtendedStop, inRoute?: BasicRoute): StopState { - const location = useLocation(); - const vans = useGetVansQuery().data; - - const stopState: StopState = {}; - - if (location !== undefined && vans !== undefined) { - const distanceMiles = geoDistanceToMiles(distance(stop, location)); - - const arrivingVans = vans - .filter( - (van) => - (inRoute === undefined || van.routeId === inRoute.id) && - van.location !== undefined && - van.location.nextStopId === stop.id, - ) - .map((van) => van.location) as VanLocation[]; - const closestStopVan = closest(arrivingVans, location); - - if (distanceMiles !== undefined) { - stopState.distanceFromUser = formatMiles(distanceMiles); - } - - if (closestStopVan !== undefined) { - stopState.vanArrivalTime = formatSecondsAsMinutes( - closestStopVan.inner.secondsToNextStop, - ); - } - } - - return stopState; -} - -/** - * A skeleton component that mimics the {@interface StopItem} component. - */ -export const StopItemSkeleton = ({ style }: ViewProps): React.JSX.Element => { - return ( - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - touchableContainer: { - borderRadius: 16, - }, - innerContainer: { - flexDirection: "row", - alignItems: "center", - paddingVertical: 16, - paddingHorizontal: 16, - }, - stopInfoContainer: { - flex: 1, - }, - stopName: { - fontSize: 24, - fontWeight: "bold", - marginBottom: 4, - }, - stopStatus: { - marginBottom: 4, - }, - stopStatusEmphasis: { - fontWeight: "bold", - }, -}); diff --git a/frontend/src/features/stops/StopList.tsx b/frontend/src/features/stops/StopList.tsx deleted file mode 100644 index a9f8449f..00000000 --- a/frontend/src/features/stops/StopList.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { BottomSheetSectionList } from "@gorhom/bottom-sheet"; -import { useFocusEffect } from "@react-navigation/native"; -import React from "react"; -import { StyleSheet, View, type ViewProps } from "react-native"; - -import Divider from "../../common/components/Divider"; -import SkeletonList from "../../common/components/SkeletonList"; -import LayoutStyle from "../../common/style/layout"; -import { useLocation } from "../location/locationSlice"; -import { distance } from "../location/util"; -import { type BasicRoute } from "../routes/routesSlice"; - -import { StopItem, StopItemSkeleton } from "./StopItem"; -import { type BasicStop, type ExtendedStop } from "./stopsSlice"; - -interface StopListProps extends ViewProps { - stops?: ExtendedStop[]; - inRoute?: BasicRoute; - renderRoute?: (route: BasicRoute) => React.JSX.Element; - renderRouteSkeleton?: () => React.JSX.Element; - /** Called when the stop item is clicked on. */ - onPress: (stop: BasicStop) => void; -} - -/** - * Screen that displays a list of stops. - */ -const StopList = ({ - stops, - inRoute, - renderRoute, - renderRouteSkeleton, - onPress, -}: StopListProps): React.JSX.Element => { - const sortedStops = sortStopsByDistance(stops); - return ( - - {sortedStops !== undefined ? ( - ( - - )} - renderSectionHeader={({ section: { route } }) => { - if ( - renderRoute === undefined || - renderRouteSkeleton === undefined - ) { - return null; - } - return route !== undefined - ? renderRoute(route) - : renderRouteSkeleton(); - }} - keyExtractor={(item) => item.id.toString()} - ItemSeparatorComponent={Divider} - /> - ) : ( - } - /> - )} - - ); -}; - -function sortStopsByDistance(stops?: BasicStop[]): BasicStop[] | undefined { - const location = useLocation(); - if (stops === undefined || location === undefined) { - return undefined; - } - - return stops.sort((a, b) => { - const aDistance = distance(location, a); - const bDistance = distance(location, b); - return aDistance - bDistance; - }); -} - -const styles = StyleSheet.create({ - stopContainer: { - paddingHorizontal: 8, - }, - loadingContainer: { - padding: 16, - }, - header: { - textAlign: "center", - paddingBottom: 16, - }, -}); - -export default StopList; diff --git a/frontend/src/features/stops/StopRouteItem.tsx b/frontend/src/features/stops/StopRouteItem.tsx new file mode 100644 index 00000000..a8939593 --- /dev/null +++ b/frontend/src/features/stops/StopRouteItem.tsx @@ -0,0 +1,121 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import React from "react"; +import { + StyleSheet, + Text, + TouchableHighlight, + View, + type ViewProps, +} from "react-native"; + +import QueryText from "../../common/components/QueryText"; +import TextSkeleton from "../../common/components/TextSkeleton"; +import Color from "../../common/style/color"; +import { formatSecondsAsMinutes } from "../location/util"; +import { type Route } from "../routes/routesSlice"; +import { useArrivalEstimate } from "../vans/arrivalSlice"; + +import { type ParentStop } from "./stopsSlice"; + +interface StopRouteItemProps { + /** The route to show */ + route: Route; + /** The parent stop of the route. This stop should be on the given route. */ + stop: ParentStop; + /** Called when the item is pressed */ + onPress: (route: Route) => void; +} +/** + * A component that renders a single route item in a stop context. + */ +export const StopRouteItem = ({ + route, + stop, + onPress, +}: StopRouteItemProps): React.JSX.Element => { + const routeNameColorStyle = { + color: route.color, + }; + const arrivalEstimate = useArrivalEstimate(stop, route); + + return ( + { + onPress(route); + }} + underlayColor={Color.generic.selection} + style={styles.touchableContainer} + > + + + + {route.name} + + + arrivalEstimate !== undefined ? ( + <> + Next OreCart in{" "} + + {formatSecondsAsMinutes(arrivalEstimate)} + + + ) : route.isActive ? ( + <>Running + ) : ( + <>Not running + ) + } + skeletonWidth={0.6} + error={"Failed to load time estimate"} + /> + + + + + ); +}; + +/** + * A skeleton component that mimics the {@interface RouteItem} component. + */ +export const StopRouteItemSkeleton = ({ + style, +}: ViewProps): React.JSX.Element => { + return ( + + + + + + + ); +}; + +const styles = StyleSheet.create({ + touchableContainer: { + borderRadius: 16, + }, + innerContainer: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 16, + }, + routeInfoContainer: { + flex: 1, + gap: 4, + }, + routeName: { + fontSize: 24, + fontWeight: "bold", + }, + emphasis: { + fontWeight: "bold", + }, +}); diff --git a/frontend/src/features/stops/StopScreen.tsx b/frontend/src/features/stops/StopScreen.tsx index a249af6b..30bf2077 100644 --- a/frontend/src/features/stops/StopScreen.tsx +++ b/frontend/src/features/stops/StopScreen.tsx @@ -4,95 +4,83 @@ import React from "react"; import { Linking, Platform, StyleSheet, Text, View } from "react-native"; import { TouchableHighlight } from "react-native-gesture-handler"; -import ErrorMessage from "../../common/components/ErrorMessage"; +import ParentChildList from "../../common/components/ParentChildList"; +import QueryText from "../../common/components/QueryText"; import TextSkeleton from "../../common/components/TextSkeleton"; import { type InnerParamList } from "../../common/navTypes"; +import { wrapReduxQuery } from "../../common/query"; import Color from "../../common/style/color"; -import { useLocationStatus, type Coordinate } from "../location/locationSlice"; -import { distance, formatMiles, geoDistanceToMiles } from "../location/util"; -import RouteList from "../routes/RouteList"; -import { useGetRoutesQuery } from "../routes/routesSlice"; +import { useDistance, type Coordinate } from "../location/locationSlice"; +import { formatMiles, geoDistanceToMiles } from "../location/util"; +import { changeMapFocus, type MapFocus } from "../map/mapSlice"; +import { type Route } from "../routes/routesSlice"; -import { useGetStopQuery, type BasicStop } from "./stopsSlice"; +import { StopRouteItem, StopRouteItemSkeleton } from "./StopRouteItem"; +import { useGetStopQuery, type ParentStop } from "./stopsSlice"; export interface StopScreenProps { navigation: StackNavigationProp; route: RouteProp; } +/** + * Shows stop information and parent routes. Will refocus the map onto the stop/ + */ export const StopScreen = ({ route, navigation, }: StopScreenProps): React.JSX.Element => { - const { - data: stop, - isError: stopError, - refetch: refetchStops, - } = useGetStopQuery(route.params.stopId); - - const { - data: routes, - isError: routesError, - refetch: refetchRoutes, - } = useGetRoutesQuery(); - - function retryStop(): void { - refetchStops().catch(console.error); - } - - function retryRoutes(): void { - refetchRoutes().catch(console.error); - } - - let stopRoutes; - if (routes !== undefined && stop !== undefined) { - stopRoutes = routes.filter((route) => stop.routeIds.includes(route.id)); - } + const stop = useGetStopQuery(route.params.stopId); + const stopFocus: MapFocus | undefined = stop.isSuccess + ? { type: "SingleStop", stop: stop.data } + : undefined; + changeMapFocus(stopFocus); return ( - - {stopError || routesError ? ( - { - retryRoutes(); - retryStop(); - }} - /> - ) : ( - } - renderStopSkeleton={() => } - onPress={(route) => { + } + headerSkeleton={() => } + item={(stop: ParentStop, route: Route) => ( + { navigation.push("Route", { routeId: route.id }); }} /> )} - + itemSkeleton={() => } + divider="line" + query={wrapReduxQuery(stop)} + refresh={stop.refetch} + map={(stop: ParentStop) => stop.routes} + keyExtractor={(route: Route) => route.id.toString()} + bottomSheet={true} + errorMessage="Failed to load stop. Please try again." + /> ); }; -const StopHeader = ({ stop }: { stop: BasicStop }): React.JSX.Element => { - const status = useLocationStatus(); - let stopDistance; - if (status.type === "active") { - stopDistance = formatMiles( - geoDistanceToMiles(distance(stop, status.location)), - ); - } +const StopHeader = ({ stop }: { stop: ParentStop }): React.JSX.Element => { + const distance = useDistance(stop); return ( - + {stop.name} - {status.type === "active" ? ( - {stopDistance} away - ) : status.type === "initializing" ? ( - - ) : null} + ( + <> + + {formatMiles(geoDistanceToMiles(distance))} + {" "} + away + + )} + skeletonWidth={0.3} + /> { > Get Directions + Routes ); }; -const StopSkeleton = (): React.JSX.Element => { +const StopHeaderSkeleton = (): React.JSX.Element => { return ( - + + ); }; @@ -121,10 +111,18 @@ const StopSkeleton = (): React.JSX.Element => { const openDirections = (coordinate: Coordinate): void => { const location = `${coordinate.latitude},${coordinate.longitude}`; const url = Platform.select({ + // Opens Apple Maps to immediately start navigation towards the stop ios: `http://maps.apple.com/?daddr=${location}`, + // Opens Google Maps to show navigation options towards the stop android: `google.navigation:q=${location}`, }); + if (url === undefined) { + // Should never happen, just make typescript happy + console.error("Can't open directions on this platform"); + return; + } + Linking.canOpenURL(url) .then(async (supported) => { if (supported) { @@ -137,9 +135,14 @@ const openDirections = (coordinate: Coordinate): void => { console.error("Can't launch directions", err); }); }; + const styles = StyleSheet.create({ - container: { - padding: 16, + listContainer: { + paddingHorizontal: 8, + }, + headerContainer: { + paddingHorizontal: 16, + paddingTop: 16, }, stopName: { fontSize: 32, @@ -166,7 +169,7 @@ const styles = StyleSheet.create({ button: { borderRadius: 100, padding: 10, - marginTop: 16, + marginVertical: 16, alignItems: "center", }, locationButton: { @@ -183,4 +186,12 @@ const styles = StyleSheet.create({ message: { marginVertical: 16, }, + emphasis: { + fontWeight: "bold", + }, + stopHeader: { + fontSize: 20, + fontWeight: "bold", + paddingBottom: 8, + }, }); diff --git a/frontend/src/features/stops/stopsSlice.ts b/frontend/src/features/stops/stopsSlice.ts index 8f8a0291..520fcd19 100644 --- a/frontend/src/features/stops/stopsSlice.ts +++ b/frontend/src/features/stops/stopsSlice.ts @@ -1,26 +1,33 @@ import apiSlice from "../../app/apiSlice"; import { type Coordinate } from "../location/locationSlice"; +import { type Route } from "../routes/routesSlice"; /** - * A list of stops, as defined by the backend. + * A stop containing all of the common information returned by the API. */ -export type Stops = ExtendedStop[]; - -/** - * A Stop, as defined by the backend. - */ -export interface ExtendedStop extends Coordinate { +export interface Stop extends Coordinate { + /** The ID of the stop. */ id: number; + /** The name of the stop. */ name: string; - routeIds: number[]; + /** Whether the stop is currently active or has an outage. */ isActive: boolean; } -export interface BasicStop extends Coordinate { - id: number; - name: string; - routeIds: number[]; - isActive: boolean; +/** + * A stop with the colors of the routes it is on. + */ +export interface ColorStop extends Stop { + /** The colors of the Stop, derived from the route colors. */ + colors: string[]; +} + +/** + * A stop with the routes it is on. + */ +export interface ParentStop extends Stop { + /** The routes this stop is on. */ + routes: Route[]; } // --- API Definition --- @@ -32,12 +39,12 @@ export interface BasicStop extends Coordinate { const stopsApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - getStops: builder.query({ - query: () => "/stops/?include=routeIds&include=isActive", + getStops: builder.query({ + query: () => "/stops/?include=colors&include=isActive", providesTags: ["Stops"], }), - getStop: builder.query({ - query: (id) => `/stops/${id}?include=routeIds&include=isActive`, + getStop: builder.query({ + query: (id) => `/stops/${id}?include=routes&include=isActive`, providesTags: (_, __, id) => [{ type: "Stop", id }], }), }), @@ -45,5 +52,6 @@ const stopsApiSlice = apiSlice.injectEndpoints({ /** * Hook for querying the list of stops. + | GenericPerspective */ export const { useGetStopsQuery, useGetStopQuery } = stopsApiSlice; diff --git a/frontend/src/features/vans/arrivalSlice.ts b/frontend/src/features/vans/arrivalSlice.ts new file mode 100644 index 00000000..4954240d --- /dev/null +++ b/frontend/src/features/vans/arrivalSlice.ts @@ -0,0 +1,233 @@ +import { + createAction, + createSlice, + type PayloadAction, +} from "@reduxjs/toolkit"; +import Constants from "expo-constants"; +import { useEffect, useState } from "react"; + +import { useAppDispatch, useAppSelector } from "../../common/hooks"; +import { error, loading, success, type Query } from "../../common/query"; +import { type Route } from "../routes/routesSlice"; +import { type Stop } from "../stops/stopsSlice"; + +export const subscribeArrival = + createAction("arrivals/subscribe"); +export const unsubscribeArrival = createAction( + "arrivals/unsubscribe", +); + +// Makes the subsequent types easier to read, even if it's all still +// just numbers in the end. +type StopId = number; +type RouteId = number; +type Handle = number; +type Seconds = number; + +interface ArrivalSubscription { + stopId: StopId; + routeId: RouteId; +} + +interface SubscribeArrival extends ArrivalSubscription { + handle: Handle; +} + +type ArrivalSubscribers = Record; +type ArrivalTimes = Record>; + +interface ArrivalsState { + subscribers: ArrivalSubscribers; + times: ArrivalTimes; +} + +type ArrivalsResponse = Record>; +type ArrivalResult = ArrivalSuccess | ArrivalsError; + +interface ArrivalSuccess { + type: "arrivals"; + arrivals: ArrivalsResponse; +} + +interface ArrivalsError { + type: "error"; + error: string; +} + +const initialState: ArrivalsState = { + subscribers: {}, + times: {}, +}; + +/** + * The slice managing the currently stored arrivals data and arrival subscribers. This should + * not need to be used by any component. Use the hook abstractions instead. + */ +export const arrivalsSlice = createSlice({ + name: "arrivals", + initialState, + reducers: { + setSubscribers: (state, action: PayloadAction) => { + state.subscribers[action.payload.handle] = action.payload; + state.times[action.payload.handle] = loading(); + }, + removeSubscribers: (state, action: PayloadAction) => { + // I have no choice but to use dynamic delete here, Redux hates the Map object. + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state.subscribers[action.payload]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state.times[action.payload]; + }, + setArrivalTimes: (state, action: PayloadAction) => { + // The backend response will be in the same structure as our message, however + // some time estimates will be unavailable and thus undefined. Go through each + // subscription and update the time estimate if it exists. + for (const handle in state.subscribers) { + const { stopId, routeId } = state.subscribers[handle]; + state.times[handle] = success(action.payload[stopId]?.[routeId]); + } + }, + setArrivalError: (state, action: PayloadAction) => { + for (const handle in state.subscribers) { + state.times[handle] = error(action.payload); + } + }, + }, +}); + +const { setSubscribers, removeSubscribers, setArrivalTimes, setArrivalError } = + arrivalsSlice.actions; + +const stopArrivalsApiUrl = `${Constants.expoConfig?.extra?.wsApiUrl}/vans/v2/arrivals/subscribe`; + +/** + * Manage the arrival estimate websocket that will be subscribed to by other components. Should be + * done in the parent component of all subscribing components. This should only be called once. + */ +export const manageArrivalEstimates = (): void => { + const [ws, setWs] = useState(undefined); + const [intervalHandle, setIntervalHandle] = useState< + NodeJS.Timeout | undefined + >(undefined); + const dispatch = useAppDispatch(); + const subscribers = useAppSelector((state) => state.arrivals.subscribers); + const [stupidCounter, setStupidCounter] = useState(0); + + useEffect(() => { + if (ws === undefined) { + const ws = new WebSocket(stopArrivalsApiUrl); + ws.addEventListener("message", (event) => { + const result: ArrivalResult = JSON.parse(event.data); + if (result.type === "arrivals") { + dispatch(setArrivalTimes(result.arrivals)); + } else { + dispatch(setArrivalError(result.error)); + } + }); + + ws.addEventListener("open", () => { + setWs(ws); + }); + + ws.addEventListener("close", () => { + setWs(undefined); + }); + + ws.addEventListener("error", (e) => { + setWs(undefined); + dispatch(setArrivalError("Failed to connect websocket")); + }); + + // For some reason ws.send within a useEffect setInterval call doesn't work, + // but changing some state that is hooked to another useEffect that then does + // the send... does. I don't know why. Now you know why it's called stupidCounter. + const handle = setInterval(() => { + setStupidCounter((i) => i + 1); + }, 2000); + setIntervalHandle(handle); + } + return () => { + ws?.close(); + setWs(undefined); + clearInterval(intervalHandle); + setIntervalHandle(undefined); + }; + }, []); + + useEffect(() => { + // Need to accumulate all of the subscriber information into + // a single query of: { stopId: [specificRouteIdsToGetEstimates...], ...} + // we don't want to actually bundle this query format into the subscriber + // information, as it makes it to fungible and impossible for subscribers + // to change what they are subscribing to without leaving behind now useless + // queries. + const query: Record = {}; + for (const handle in subscribers) { + const { stopId, routeId } = subscribers[handle]; + if (query[stopId] === undefined) { + query[stopId] = []; + } + if (query[stopId].includes(routeId)) { + continue; + } + query[stopId].push(routeId); + } + ws?.send(JSON.stringify(query)); + }, [ws, subscribers, stupidCounter]); // Fire when opening websocket, changing subscribers, or interval events. +}; + +/** + * Subscribe for the estimated time that a shuttle will arrive on the given stop + * on the given route. The stop must be part of the route. + */ +export const useArrivalEstimate = ( + stop: Stop, + route: Route, +): Query => { + // Make up a handle to identify this subscription, allowing us to update it + // to new values while not leaving any residual query parameters behind. + const [handle] = useState(Math.random()); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setSubscribers({ handle, stopId: stop.id, routeId: route.id })); + return () => { + dispatch(removeSubscribers(handle)); + }; + }, [stop, route]); + + // There will be a weird period where useEffect has not executed yet and the + // subscription result may not be defined, so we need to handle that case and + // fall back to loading. + return useAppSelector((state) => state.arrivals.times[handle] ?? loading()); +}; + +/** + * Subscribe for the estimated time that a shuttle will arrive on the given stop + * on the given route. The stop must be part of the route. The stop can be a + * deferred query on this function. + */ +export const useArrivalEstimateQuery = ( + stop: Query, + route: Route, +): Query => { + // Make up a handle to identify this subscription, allowing us to update it + // to new values while not leaving any residual query parameters behind. + const [handle] = useState(Math.random()); + const dispatch = useAppDispatch(); + + useEffect(() => { + // Cant change amount of hooks, so we need to check if the stop is still loading. + if (!stop.isSuccess) { + return; + } + dispatch( + setSubscribers({ handle, stopId: stop.data.id, routeId: route.id }), + ); + return () => { + dispatch(removeSubscribers(handle)); + }; + }, [stop.data, route]); // Rely on stop data, since techincally the queries passed here may be new instances. + + return useAppSelector((state) => state.arrivals.times[handle] ?? loading()); +}; diff --git a/frontend/src/features/vans/locations.ts b/frontend/src/features/vans/locations.ts new file mode 100644 index 00000000..54c255bd --- /dev/null +++ b/frontend/src/features/vans/locations.ts @@ -0,0 +1,110 @@ +import Constants from "expo-constants"; +import { useEffect, useState } from "react"; + +import { error, loading, success, type Query } from "../../common/query"; +import { type Coordinate } from "../location/locationSlice"; + +const vanLocationApiUrl = `${Constants.expoConfig?.extra?.wsApiUrl}/vans/v2/subscribe/`; + +export interface VanLocation { + /** The identifier of the van that is currently at this location */ + guid: number; + /** The color of the route this van is currently on */ + color: string; + /** The current location of the van. */ + location: Coordinate; +} + +interface VanLocationMessage { + include: string[]; + query: VanLocationQuery; +} + +interface VanLocationQuery { + type: "vans" | "van"; + guid?: number; + alive?: boolean; + routeIds?: number[]; +} + +type VanLocationResponse = VanLocationSuccess | VanLocationError; + +interface VanLocationSuccess { + type: "vans"; + vans: VanLocation[]; +} + +interface VanLocationError { + type: "error"; + error: string; +} + +/** + * Manage a van location websocket and get the resulting van locations from it. + * This is only designed this way since one component (map) uses it. If you need + * it in multiple places, you'll have to convert it to a subscriber system like in + * arrivalSlice.ts. + */ +export const useVanLocations = (): Query => { + const [ws, setWs] = useState(undefined); + const [intervalHandle, setIntervalHandle] = useState< + NodeJS.Timeout | undefined + >(undefined); + const [locations, setLocations] = useState>(loading()); + const [stupidCounter, setStupidCounter] = useState(0); + + useEffect(() => { + if (ws === undefined) { + const ws = new WebSocket(vanLocationApiUrl); + ws.addEventListener("message", (event) => { + const result: VanLocationResponse = JSON.parse(event.data); + if (result.type === "vans") { + setLocations(success(result.vans)); + } else { + setLocations(error(result.error)); + } + }); + + ws.addEventListener("open", () => { + setWs(ws); + }); + + ws.addEventListener("close", () => { + setWs(undefined); + }); + + ws.addEventListener("error", (e) => { + setWs(undefined); + setLocations(error("Failed to connect to websocket")); + }); + + // For some reason ws.send within a useEffect setInterval call doesn't work, + // but changing some state that is hooked to another useEffect that then does + // the send... does. I don't know why. Now you know why it's called stupidCounter. + const handle = setInterval(() => { + setStupidCounter((i) => i + 1); + }, 2000); + setIntervalHandle(handle); + } + + return () => { + ws?.close(); + setWs(undefined); + clearInterval(intervalHandle); + setIntervalHandle(undefined); + }; + }, []); + + useEffect(() => { + const message: VanLocationMessage = { + include: ["color", "location"], // Location is the whole point, and color is useful for the map. + query: { + type: "vans", + alive: true, + }, + }; + ws?.send(JSON.stringify(message)); + }, [ws, stupidCounter]); // Fire when opening the websocket or when the interval is fired. + + return locations; +}; diff --git a/frontend/src/features/vans/vansSlice.ts b/frontend/src/features/vans/vansSlice.ts deleted file mode 100644 index bab073bb..00000000 --- a/frontend/src/features/vans/vansSlice.ts +++ /dev/null @@ -1,97 +0,0 @@ -import Constants from "expo-constants"; - -import apiSlice from "../../app/apiSlice"; -import { type Coordinate } from "../location/locationSlice"; - -/** - * A list of vans, as defined by the backend. - */ -export type Vans = Van[]; - -/** - * A van, as defined by the backend. - */ -export interface Van { - id: number; - routeId: number; - guid: string; - wheelchair: boolean; - location?: VanLocation; -} - -// --- API Definition --- - -type VanLocations = Record; - -export interface VanLocation extends Coordinate { - nextStopId: number; - secondsToNextStop: number; -} - -const vanLocationApiUrl = `${Constants.expoConfig?.extra?.wsApiUrl}/vans/location/subscribe/`; - -/** - * This slice extends the existing API slice with the van route and companion - * location websocket connection. There is no state to reason about here regarding - * the route, it's just configuration. However, we do need to manage the websocket as - * a side effect of the van route being fetched/cached/removed. - */ -const vansApiSlice = apiSlice.injectEndpoints({ - endpoints: (builder) => ({ - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - getVans: builder.query({ - query: () => "/vans/", - async onCacheEntryAdded( - _, - { updateCachedData, cacheDataLoaded, cacheEntryRemoved }, - ) { - // By coupling location tracking with the cached vans fetch results, - // we can not only collate van information with location information, - // but also ensure that the websocket is closed when the cache entry - // is removed. - const ws = new WebSocket(vanLocationApiUrl); - - const locationListener = (event: MessageEvent): void => { - const locations: VanLocations = JSON.parse(event.data); - updateCachedData((vans) => { - for (const id in locations) { - // Need to convert from the stringed JSON IDs to numbered ones. - const vanId = parseInt(id, 10); - const van = vans.find((van) => van.id === vanId); - if (van !== undefined) { - van.location = locations[id]; - } - } - }); - }; - - const errorListener = (event: Event): void => { - console.error("Unable to track van location", event); - }; - - try { - await cacheDataLoaded; - ws.addEventListener("message", locationListener); - ws.addEventListener("error", errorListener); - } catch { - // no-op in case `cacheEntryRemoved` resolves before `cacheDataLoaded`, - // in which case `cacheDataLoaded` will throw - console.error( - "Cache entry removed before cache data loaded, ignoring", - ); - } - - await cacheEntryRemoved; - ws.close(); - ws.removeEventListener("message", locationListener); - ws.removeEventListener("error", errorListener); - }, - providesTags: ["Vans"], - }), - }), -}); - -/** - * Hook for querying the list of vans. - */ -export const { useGetVansQuery } = vansApiSlice;