diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index a1f305608d..e4c129b93e 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -8,6 +8,7 @@ from aiohttp.client import ClientSession from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession from app.auto_spatial_advisory.fuel_type_layer import ( get_fuel_type_raster_by_year, @@ -17,23 +18,27 @@ get_fuel_type_area_stats, get_zone_wind_stats_for_source_id, ) -from wps_shared.auth import audit, asa_authentication_required, audit_asa +from wps_shared.auth import asa_authentication_required, audit, audit_asa from wps_shared.db.crud.auto_spatial_advisory import ( get_all_hfi_thresholds_by_id, get_all_sfms_fuel_type_records, + get_all_zone_source_ids, get_centre_tpi_stats, get_fire_centre_tpi_fuel_areas, get_hfi_area, get_min_wind_speed_hfi_thresholds, get_most_recent_run_datetime_for_date, + get_most_recent_run_datetime_for_date_range, get_precomputed_stats_for_shape, get_provincial_rollup, get_run_datetimes, get_sfms_bounds, + get_tpi_fuel_areas, + get_tpi_stats, get_zone_source_ids_in_centre, ) from wps_shared.db.database import get_async_read_session_scope -from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum, TPIClassEnum +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum, SFMSFuelType, TPIClassEnum from wps_shared.schemas.fba import ( FireCenterListResponse, FireCentreTPIResponse, @@ -42,10 +47,14 @@ FireShapeAreaListResponse, FireZoneHFIStats, FireZoneTPIStats, + HFIStatsResponse, LatestSFMSRunParameter, + LatestSFMSRunParameterRangeResponse, LatestSFMSRunParameterResponse, ProvincialSummaryResponse, SFMSBoundsResponse, + SFMSRunParameter, + TPIResponse, ) from wps_shared.wildfire_one.wfwx_api import get_auth_header, get_fire_centers @@ -57,6 +66,84 @@ ) +async def get_all_zone_data_for_source_ids( + session: AsyncSession, + zone_source_ids: List[str], + run_type: RunType, + for_date: date, + run_datetime: datetime, +): + # get fuel type ids data + fuel_types = await get_all_sfms_fuel_type_records(session) + fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year) + zone_wind_stats_by_source_id = {} + hfi_thresholds_by_id = await get_all_hfi_thresholds_by_id(session) + advisory_wind_speed_by_source_id = await get_min_wind_speed_hfi_thresholds( + session, zone_source_ids, run_type, run_datetime, for_date + ) + for source_id, wind_speed_stats in advisory_wind_speed_by_source_id.items(): + min_wind_stats = get_zone_wind_stats_for_source_id(wind_speed_stats, hfi_thresholds_by_id) + zone_wind_stats_by_source_id[source_id] = min_wind_stats + + all_zone_data: dict[int, FireZoneHFIStats] = {} + for zone_source_id in zone_source_ids: + # get HFI/fuels data for specific zone + hfi_fuel_type_ids_for_zone = await get_precomputed_stats_for_shape( + session, + run_type=RunTypeEnum(run_type.value), + for_date=for_date, + run_datetime=run_datetime, + source_identifier=zone_source_id, + fuel_type_raster_id=fuel_type_raster.id, + ) + + if hfi_fuel_type_ids_for_zone is None or len(hfi_fuel_type_ids_for_zone) == 0: + # Handle the situation where data for the current year was actually processed with + # last year's fuel grid + prev_fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year - 1) + hfi_fuel_type_ids_for_zone = await get_precomputed_stats_for_shape( + session, + run_type=RunTypeEnum(run_type.value), + for_date=for_date, + run_datetime=run_datetime, + source_identifier=zone_source_id, + fuel_type_raster_id=prev_fuel_type_raster.id, + ) + + zone_fuel_stats = [] + for ( + critical_hour_start, + critical_hour_end, + fuel_type_id, + threshold_id, + area, + fuel_area, + percent_conifer, + ) in hfi_fuel_type_ids_for_zone: + hfi_threshold = hfi_thresholds_by_id.get(threshold_id) + if hfi_threshold is None: + logger.error(f"No hfi threshold for id: {threshold_id}") + continue + fuel_type_area_stats = get_fuel_type_area_stats( + for_date, + fuel_types, + hfi_threshold, + percent_conifer, + critical_hour_start, + critical_hour_end, + fuel_type_id, + area, + fuel_area, + ) + zone_fuel_stats.append(fuel_type_area_stats) + + all_zone_data[int(zone_source_id)] = FireZoneHFIStats( + min_wind_stats=zone_wind_stats_by_source_id.get(int(zone_source_id), []), + fuel_area_stats=zone_fuel_stats, + ) + return all_zone_data + + @router.get("/fire-centers", response_model=FireCenterListResponse) async def get_all_fire_centers(_=Depends(asa_authentication_required)): """Returns fire centers for all active stations.""" @@ -81,21 +168,20 @@ async def get_shapes( async with get_async_read_session_scope() as session: fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year) shapes = [] - rows = await get_hfi_area( session, RunTypeEnum(run_type.value), run_datetime, for_date, fuel_type_raster.id ) # Fetch rows. for row in rows: - combustible_area = row.combustible_area # type: ignore - hfi_area = row.hfi_area # type: ignore + combustible_area = row.combustible_area + hfi_area = row.hfi_area shapes.append( FireShapeArea( - fire_shape_id=row.source_identifier, # type: ignore - threshold=row.threshold, # type: ignore - combustible_area=row.combustible_area, # type: ignore - elevated_hfi_area=row.hfi_area, # type: ignore + fire_shape_id=row.source_identifier, + threshold=row.threshold, + combustible_area=row.combustible_area, + elevated_hfi_area=row.hfi_area, elevated_hfi_percentage=hfi_area / combustible_area * 100, ) ) @@ -161,88 +247,19 @@ async def get_hfi_fuels_data_for_fire_centre( ) async with get_async_read_session_scope() as session: - # get fuel type ids data - fuel_types = await get_all_sfms_fuel_type_records(session) # get fire zone id's within a fire centre zone_source_ids = await get_zone_source_ids_in_centre(session, fire_centre_name) - fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year) - zone_wind_stats_by_source_id = {} - hfi_thresholds_by_id = await get_all_hfi_thresholds_by_id(session) - advisory_wind_speed_by_source_id = await get_min_wind_speed_hfi_thresholds( - session, zone_source_ids, run_type, run_datetime, for_date + all_zone_data = await get_all_zone_data_for_source_ids( + session, zone_source_ids, run_type, for_date, run_datetime ) - for source_id, wind_speed_stats in advisory_wind_speed_by_source_id.items(): - min_wind_stats = get_zone_wind_stats_for_source_id( - wind_speed_stats, hfi_thresholds_by_id - ) - zone_wind_stats_by_source_id[source_id] = min_wind_stats - - all_zone_data: dict[int, FireZoneHFIStats] = {} - for zone_source_id in zone_source_ids: - # get HFI/fuels data for specific zone - hfi_fuel_type_ids_for_zone = await get_precomputed_stats_for_shape( - session, - run_type=RunTypeEnum(run_type.value), - for_date=for_date, - run_datetime=run_datetime, - source_identifier=zone_source_id, - fuel_type_raster_id=fuel_type_raster.id, - ) - if hfi_fuel_type_ids_for_zone is None or len(hfi_fuel_type_ids_for_zone) == 0: - # Handle the situation where data for the current year was actually processed with - # last year's fuel grid - prev_fuel_type_raster = await get_fuel_type_raster_by_year( - session, for_date.year - 1 - ) - hfi_fuel_type_ids_for_zone = await get_precomputed_stats_for_shape( - session, - run_type=RunTypeEnum(run_type.value), - for_date=for_date, - run_datetime=run_datetime, - source_identifier=zone_source_id, - fuel_type_raster_id=prev_fuel_type_raster.id, - ) - - zone_fuel_stats = [] - - for ( - critical_hour_start, - critical_hour_end, - fuel_type_id, - threshold_id, - area, - fuel_area, - percent_conifer, - ) in hfi_fuel_type_ids_for_zone: - hfi_threshold = hfi_thresholds_by_id.get(threshold_id) - if hfi_threshold is None: - logger.error(f"No hfi threshold for id: {threshold_id}") - continue - fuel_type_area_stats = get_fuel_type_area_stats( - for_date, - fuel_types, - hfi_threshold, - percent_conifer, - critical_hour_start, - critical_hour_end, - fuel_type_id, - area, - fuel_area, - ) - zone_fuel_stats.append(fuel_type_area_stats) - - all_zone_data[int(zone_source_id)] = FireZoneHFIStats( - min_wind_stats=zone_wind_stats_by_source_id.get(int(zone_source_id), []), - fuel_area_stats=zone_fuel_stats, - ) - - return {fire_centre_name: all_zone_data} + return {fire_centre_name: all_zone_data} @router.get("/latest-sfms-run-datetime/{for_date}", response_model=LatestSFMSRunParameterResponse) async def get_latest_sfms_run_datetime_for_date( - for_date: date, _=Depends(asa_authentication_required) + for_date: date, + _=Depends(asa_authentication_required), ): async with get_async_read_session_scope() as session: latest_run_parameter = await get_most_recent_run_datetime_for_date(session, for_date) @@ -256,23 +273,6 @@ async def get_latest_sfms_run_datetime_for_date( return LatestSFMSRunParameterResponse(run_parameter=run_parameter) -@router.get("/sfms-run-datetimes/{run_type}/{for_date}", response_model=List[datetime]) -async def get_run_datetimes_for_date_and_runtype( - run_type: RunType, for_date: date, _=Depends(asa_authentication_required) -): - """Return list of datetimes for which SFMS has run, given a specific for_date and run_type. - Datetimes should be ordered with most recent first.""" - async with get_async_read_session_scope() as session: - datetimes = [] - - rows = await get_run_datetimes(session, RunTypeEnum(run_type.value), for_date) - - for row in rows: - datetimes.append(row.run_datetime) # type: ignore - - return datetimes - - @router.get("/sfms-run-bounds", response_model=SFMSBoundsResponse) async def get_sfms_run_bounds(): async with get_async_read_session_scope() as session: @@ -305,7 +305,6 @@ async def get_fire_centre_tpi_stats( tpi_fuel_stats = await get_fire_centre_tpi_fuel_areas( session, fire_centre_name, fuel_type_raster.id ) - hfi_tpi_areas_by_zone = [] for row in tpi_stats_for_centre: fire_zone_id = row.source_identifier @@ -340,3 +339,125 @@ async def get_fire_centre_tpi_stats( return FireCentreTPIResponse( fire_centre_name=fire_centre_name, firezone_tpi_stats=hfi_tpi_areas_by_zone ) + + +@router.get("/sfms-run-datetimes/{run_type}/{for_date}", response_model=List[datetime]) +async def get_run_datetimes_for_date_and_runtype( + run_type: RunType, + for_date: date, + _=Depends(asa_authentication_required), +): + """Return list of datetimes for which SFMS has run, given a specific for_date and run_type. + Datetimes should be ordered with most recent first.""" + async with get_async_read_session_scope() as session: + datetimes = [] + + rows = await get_run_datetimes(session, RunTypeEnum(run_type.value), for_date) + + for row in rows: + datetimes.append(row.run_datetime) + + return datetimes + + +#### ASA Go Specific Routes #### + + +@router.get( + "/latest-sfms-run-parameters/{start_date}/{end_date}", + response_model=LatestSFMSRunParameterRangeResponse, +) +async def get_latest_sfms_run_datetime_for_date_range( + start_date: date, + end_date: date, + _=Depends(asa_authentication_required), +): + async with get_async_read_session_scope() as session: + result = await get_most_recent_run_datetime_for_date_range(session, start_date, end_date) + latest_run_parameters = {} + for row in result: + run_parameter = SFMSRunParameter( + for_date=row.for_date, run_type=row.run_type, run_datetime=row.run_datetime + ) + latest_run_parameters[row.for_date] = run_parameter + return LatestSFMSRunParameterRangeResponse(run_parameters=latest_run_parameters) + + +@router.get( + "/hfi-stats/{run_type}/{run_datetime}/{for_date}", + response_model=HFIStatsResponse, +) +async def get_hfi_fuels_data_for_run_parameter( + run_type: RunType, + run_datetime: datetime, + for_date: date, + _=Depends(asa_authentication_required), +): + """ + Fetch fuel type and critical hours data for all fire zones units + """ + logger.info( + "hfi-stats/%s/%s/%s", + run_type.value, + for_date, + run_datetime, + ) + + async with get_async_read_session_scope() as session: + # get fire zone id's within a fire centre + zone_source_ids = await get_all_zone_source_ids(session) + all_zone_data = await get_all_zone_data_for_source_ids( + session, zone_source_ids, run_type, for_date, run_datetime + ) + + return HFIStatsResponse(zone_data=all_zone_data) + + +@router.get( + "/tpi-stats/{run_type}/{run_datetime}/{for_date}", + response_model=TPIResponse, +) +async def get_tpi_stats_for_run_parameter( + run_type: RunType, + run_datetime: datetime, + for_date: date, + _=Depends(asa_authentication_required), +): + """Return the elevation TPI statistics for each advisory threshold for all fire shapes""" + logger.info("/fba/tpi-stats/") + async with get_async_read_session_scope() as session: + tpi_stats = await get_tpi_stats(session, run_type, run_datetime, for_date) + fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year) + tpi_fuel_stats = await get_tpi_fuel_areas(session, fuel_type_raster.id) + hfi_tpi_areas_by_zone = [] + for row in tpi_stats: + fire_zone_id = row.source_identifier + square_metres = math.pow(row.pixel_size_metres, 2) + tpi_fuel_stats_for_zone = [ + stats for stats in tpi_fuel_stats if stats[2] == fire_zone_id + ] + valley_bottom_tpi = None + mid_slope_tpi = None + upper_slope_tpi = None + + for tpi_fuel_stat in tpi_fuel_stats_for_zone: + if tpi_fuel_stat[0] == TPIClassEnum.valley_bottom: + valley_bottom_tpi = tpi_fuel_stat[1] + elif tpi_fuel_stat[0] == TPIClassEnum.mid_slope: + mid_slope_tpi = tpi_fuel_stat[1] + elif tpi_fuel_stat[0] == TPIClassEnum.upper_slope: + upper_slope_tpi = tpi_fuel_stat[1] + + hfi_tpi_areas_by_zone.append( + FireZoneTPIStats( + fire_zone_id=fire_zone_id, + valley_bottom_hfi=row.valley_bottom * square_metres, + valley_bottom_tpi=valley_bottom_tpi, + mid_slope_hfi=row.mid_slope * square_metres, + mid_slope_tpi=mid_slope_tpi, + upper_slope_hfi=row.upper_slope * square_metres, + upper_slope_tpi=upper_slope_tpi, + ) + ) + + return TPIResponse(firezone_tpi_stats=hfi_tpi_areas_by_zone) diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index bd74967fad..97d7d82379 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -1,3 +1,4 @@ +import asyncio import json import math from collections import namedtuple @@ -18,7 +19,14 @@ TPIClassEnum, ) from wps_shared.db.models.fuel_type_raster import FuelTypeRaster -from wps_shared.schemas.fba import HfiThreshold +from wps_shared.schemas.auto_spatial_advisory import SFMSRunType +from wps_shared.schemas.fba import ( + FireZoneHFIStats, + HFIStatsResponse, + HfiThreshold, + LatestSFMSRunParameterRangeResponse, + SFMSRunParameter, +) from wps_shared.tests.common import default_mock_client_get mock_fire_centre_name = "PGFireCentre" @@ -33,11 +41,11 @@ ) get_sfms_run_datetimes_url = "/api/fba/sfms-run-datetimes/forecast/2022-09-27" get_sfms_run_bounds_url = "/api/fba/sfms-run-bounds" +get_tpi_stats_url = "api/fba/tpi-stats/forecast/2022-09-27/2022-09-27" decode_fn = "jwt.decode" -mock_tpi_stats = AdvisoryTPIStats( - id=1, advisory_shape_id=1, valley_bottom=1, mid_slope=2, upper_slope=3, pixel_size_metres=50 -) + +mock_tpi_stats_empty = [] mock_fire_centre_info = [(9.0, 11.0, 1, 1, 50, 100, 1)] mock_fire_centre_info_with_grass = [(9.0, 11.0, 12, 1, 50, 100, None)] @@ -73,7 +81,6 @@ ], ) - def create_mock_centre_tpi_stats( advisory_shape_id, source_identifier, valley_bottom, mid_slope, upper_slope, pixel_size_metres ): @@ -90,6 +97,74 @@ def create_mock_centre_tpi_stats( mock_centre_tpi_stats_1 = create_mock_centre_tpi_stats(1, 1, 1, 2, 3, 2) mock_centre_tpi_stats_2 = create_mock_centre_tpi_stats(2, 2, 1, 2, 3, 2) +TPIFuelAreasResponse = namedtuple( + "TPIFuelAreasResponse", + [ + "tpi_class", + "fuel_area", + "source_identifier", + "id", + "name", + ], +) + + +def mock_create_tpi_fuel_area(tpi_class, fuel_area, source_identifier, id, name): + return TPIFuelAreasResponse( + tpi_class=tpi_class, + fuel_area=fuel_area, + source_identifier=source_identifier, + id=id, + name=name, + ) + + +mock_tpi_fuel_area_1 = mock_create_tpi_fuel_area( + TPIClassEnum.valley_bottom, 100, "20", 2, "Coastal" +) +mock_tpi_fuel_area_2 = mock_create_tpi_fuel_area(TPIClassEnum.mid_slope, 200, "20", 2, "Coastal") +mock_tpi_fuel_area_3 = mock_create_tpi_fuel_area(TPIClassEnum.upper_slope, 300, "20", 2, "Coastal") + +TPIStatsResponse = namedtuple( + "TPIStatsResponse", + [ + "advisory_shape_id", + "source_identifier", + "valley_bottom", + "mid_slope", + "upper_slope", + "pixel_size_metres", + "fire_centre_id", + "fire_centre_name", + ], +) + + +def create_mock_tpi_stats( + advisory_shape_id, + source_identifier, + valley_bottom, + mid_slope, + upper_slope, + pixel_size_metres, + fire_centre_id, + fire_centre_name, +): + return TPIStatsResponse( + advisory_shape_id=advisory_shape_id, + source_identifier=source_identifier, + valley_bottom=valley_bottom, + mid_slope=mid_slope, + upper_slope=upper_slope, + pixel_size_metres=pixel_size_metres, + fire_centre_id=fire_centre_id, + fire_centre_name=fire_centre_name, + ) + + +mock_tpi_stats_1 = create_mock_tpi_stats(1, 1, 1, 2, 3, 2, 1, "foo") +mock_tpi_stats_2 = create_mock_tpi_stats(2, 2, 1, 2, 3, 2, 2, "bar") + CentreTPIFuelAreasResponse = namedtuple( "CentreTPIFuelAreasResponse", ["tpi_class", "fuel_area", "source_identifier"] @@ -125,8 +200,8 @@ async def mock_get_auth_header(*_, **__): return {} -async def mock_get_tpi_stats(*_, **__): - return mock_tpi_stats +async def mock_get_tpi_stats_empty(*_, **__): + return mock_tpi_stats_empty async def mock_get_tpi_stats_none(*_, **__): @@ -149,6 +224,10 @@ async def mock_get_centre_tpi_stats(*_, **__): return [mock_centre_tpi_stats_1, mock_centre_tpi_stats_2] +async def mock_get_tpi_stats(*_, **__): + return [mock_tpi_stats_1, mock_tpi_stats_2] + + async def mock_get_fire_centre_tpi_fuel_areas(*_, **__): return [mock_centre_tpi_fuel_area_1, mock_centre_tpi_fuel_area_2, mock_centre_tpi_fuel_area_3] @@ -168,6 +247,32 @@ async def mock_get_sfms_bounds_no_data(*_, **__): return [] +async def mock_get_most_recent_run_datetime_for_date_range(*_, **__): + for_date_1 = date(2025, 8, 25) + for_date_2 = date(2025, 8, 26) + run_datetime = datetime(2025, 8, 25) + run_parameter_1 = SFMSRunParameter( + for_date=for_date_1, run_datetime=run_datetime, run_type=SFMSRunType.FORECAST + ) + run_parameter_2 = SFMSRunParameter( + for_date=for_date_2, run_datetime=run_datetime, run_type=SFMSRunType.FORECAST + ) + return [run_parameter_1, run_parameter_2] + + +async def mock_get_all_zone_source_ids(*_, **__): + return [1, 2, 3] + + +async def mock_get_tpi_fuel_areas(*_, **__): + return [mock_tpi_fuel_area_1, mock_centre_tpi_fuel_area_2, mock_tpi_fuel_area_3] + + +async def mock_get_hfi_fuels_data_for_run_parameter(*_, **__): + mock_fire_zone_hfi_stats = FireZoneHFIStats(min_wind_stats=[], fuel_area_stats=[]) + return HFIStatsResponse(zone_data={1: mock_fire_zone_hfi_stats}) + + @pytest.fixture() def client(): from app.main import app as test_app @@ -205,6 +310,7 @@ def test_fba_endpoint_fire_centers(status, expected_fire_centers, monkeypatch): get_fire_centre_info_url, get_sfms_run_datetimes_url, get_sfms_run_bounds_url, + get_tpi_stats_url, ], ) def test_get_endpoints_unauthorized(client: TestClient, endpoint: str): @@ -406,6 +512,32 @@ def test_get_fire_centre_tpi_stats_authorized(client: TestClient): assert json_response["firezone_tpi_stats"][1]["mid_slope_tpi"] is None assert json_response["firezone_tpi_stats"][1]["upper_slope_tpi"] is None +@pytest.mark.usefixtures("mock_jwt_decode") +@patch("app.routers.fba.get_auth_header", mock_get_auth_header) +@patch("app.routers.fba.get_tpi_stats", mock_get_tpi_stats) +@patch("app.routers.fba.get_fuel_type_raster_by_year", mock_get_fuel_type_raster_by_year) +@patch("app.routers.fba.get_tpi_fuel_areas", mock_get_tpi_fuel_areas) +def test_get_tpi_stats_authorized(client: TestClient): + """Allowed to get tpi stats for run parameters when authorized""" + response = client.get(get_tpi_stats_url) + + json_response = response.json() + assert response.status_code == 200 + assert json_response["firezone_tpi_stats"][0]["fire_zone_id"] == 1 + assert json_response["firezone_tpi_stats"][0]["valley_bottom_hfi"] == 4 + assert json_response["firezone_tpi_stats"][0]["valley_bottom_tpi"] is None + assert json_response["firezone_tpi_stats"][0]["mid_slope_hfi"] == 8 + assert math.isclose(json_response["firezone_tpi_stats"][0]["mid_slope_tpi"], 2.0) + assert json_response["firezone_tpi_stats"][0]["upper_slope_hfi"] == 12 + assert json_response["firezone_tpi_stats"][0]["upper_slope_tpi"] is None + assert json_response["firezone_tpi_stats"][1]["fire_zone_id"] == 2 + assert json_response["firezone_tpi_stats"][1]["valley_bottom_hfi"] == 4 + assert json_response["firezone_tpi_stats"][1]["valley_bottom_tpi"] is None + assert json_response["firezone_tpi_stats"][1]["mid_slope_hfi"] == 8 + assert json_response["firezone_tpi_stats"][1]["mid_slope_tpi"] is None + assert json_response["firezone_tpi_stats"][1]["upper_slope_hfi"] == 12 + assert json_response["firezone_tpi_stats"][1]["upper_slope_tpi"] is None + @pytest.mark.usefixtures("mock_jwt_decode") @patch("app.routers.fba.get_auth_header", mock_get_auth_header) @@ -440,6 +572,9 @@ def test_get_sfms_run_bounds_no_bounds(client: TestClient): "/api/fba/fire-centre-tpi-stats/forecast/2024-08-10/2024-08-10/PGFireCentre", "/api/fba/sfms-run-datetimes/forecast/2022-09-27", "/api/fba/sfms-run-bounds", + "/api/fba/latest-sfms-run-parameters/2025-08-25/2025-08-26", + "/api/fba/hfi-stats/forecast/2025-08-25T15:01:47.340947Z/2025-08-26", + "/api/fba/tpi-stats/forecast/2025-08-25T15:01:47.340947Z/2025-08-26", ] @@ -456,8 +591,19 @@ def test_get_sfms_run_bounds_no_bounds(client: TestClient): @patch("app.routers.fba.get_fuel_type_raster_by_year", mock_get_fuel_type_raster_by_year) @patch("app.routers.fba.get_fire_centre_tpi_fuel_areas", mock_get_fire_centre_tpi_fuel_areas) @patch("app.routers.fba.get_centre_tpi_stats", mock_get_centre_tpi_stats) +@patch("app.routers.fba.get_tpi_stats", mock_get_tpi_stats) @patch("app.routers.fba.get_run_datetimes", mock_get_sfms_run_datetimes) @patch("app.routers.fba.get_sfms_bounds", mock_get_sfms_bounds) +@patch( + "app.routers.fba.get_most_recent_run_datetime_for_date_range", + mock_get_most_recent_run_datetime_for_date_range, +) +@patch( + "app.routers.fba.get_all_zone_source_ids", + mock_get_all_zone_source_ids, +) +@patch("app.routers.fba.get_tpi_fuel_areas", mock_get_tpi_fuel_areas) +@patch("app.routers.fba.get_tpi_stats", mock_get_tpi_stats) def test_fba_endpoints_allowed_for_test_idir(client, endpoint): headers = {"Authorization": "Bearer token"} response = client.get(endpoint, headers=headers) diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index 31561f2d97..2a846c27b1 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -4,26 +4,33 @@ import BottomNavigationBar from "@/components/BottomNavigationBar"; import ASAGoMap from "@/components/map/ASAGoMap"; import Profile from "@/components/profile/Profile"; import Advisory from "@/components/report/Advisory"; +import TabPanel from "@/components/TabPanel"; import { useAppIsActive } from "@/hooks/useAppIsActive"; +import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; +import { fetchAndCacheData } from "@/slices/dataSlice"; +import { today } from "@/utils/dataSliceUtils"; import { fetchFireCenters } from "@/slices/fireCentersSlice"; -import { fetchFireCentreHFIFuelStats } from "@/slices/fireCentreHFIFuelStatsSlice"; -import { fetchFireCentreTPIStats } from "@/slices/fireCentreTPIStatsSlice"; -import { fetchFireShapeAreas } from "@/slices/fireZoneAreasSlice"; import { startWatchingLocation, stopWatchingLocation, } from "@/slices/geolocationSlice"; import { updateNetworkStatus } from "@/slices/networkStatusSlice"; -import { fetchProvincialSummary } from "@/slices/provincialSummarySlice"; -import { fetchMostRecentSFMSRunParameter } from "@/slices/runParameterSlice"; -import { AppDispatch, selectFireCenters, selectRunParameter } from "@/store"; -import TabPanel from "@/components/TabPanel"; +import { fetchSFMSRunParameters } from "@/slices/runParametersSlice"; +import { + AppDispatch, + selectFireCenters, + selectNetworkStatus, + selectRunParameters, +} from "@/store"; import { theme } from "@/theme"; -import { NavPanel, PST_UTC_OFFSET } from "@/utils/constants"; +import { NavPanel } from "@/utils/constants"; +import { PMTilesCache } from "@/utils/pmtilesCache"; +import { clearStaleHFIPMTiles } from "@/utils/storage"; +import { Filesystem } from "@capacitor/filesystem"; import { ConnectionStatus, Network } from "@capacitor/network"; import { Box } from "@mui/material"; import { LicenseInfo } from "@mui/x-license-pro"; -import { isNull, isUndefined } from "lodash"; +import { isNil, isNull } from "lodash"; import { DateTime } from "luxon"; import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; @@ -32,10 +39,10 @@ const ADVISORY_THRESHOLD = 20; const App = () => { LicenseInfo.setLicenseKey(import.meta.env.VITE_MUI_LICENSE_KEY); - const isActive = useAppIsActive(); const dispatch: AppDispatch = useDispatch(); - const { fireCenters } = useSelector(selectFireCenters); + + // local state const [tab, setTab] = useState(NavPanel.MAP); const [fireCenter, setFireCenter] = useState( undefined @@ -43,10 +50,15 @@ const App = () => { const [selectedFireShape, setSelectedFireShape] = useState< FireShape | undefined >(undefined); - const [dateOfInterest, setDateOfInterest] = useState( - DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`) - ); - const { runDatetime, runType } = useSelector(selectRunParameter); + const [dateOfInterest, setDateOfInterest] = useState(today); + + // selected redux state + const { fireCenters } = useSelector(selectFireCenters); + const { networkStatus } = useSelector(selectNetworkStatus); + const runParameters = useSelector(selectRunParameters); + + // hooks + const runParameter = useRunParameterForDate(dateOfInterest); useEffect(() => { // Network status is disconnected by default in the networkStatusSlice. Update the status @@ -68,20 +80,26 @@ const App = () => { }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (networkStatus.connected) { + dispatch(fetchSFMSRunParameters()); + } + }, [networkStatus.connected, dispatch]); + useEffect(() => { dispatch(fetchFireCenters()); const doiISODate = dateOfInterest.toISODate(); if (!isNull(doiISODate)) { - dispatch(fetchMostRecentSFMSRunParameter(doiISODate)); + dispatch(fetchSFMSRunParameters()); } }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const doiISODate = dateOfInterest.toISODate(); if (!isNull(doiISODate)) { - dispatch(fetchMostRecentSFMSRunParameter(doiISODate)); + dispatch(fetchSFMSRunParameters()); } - }, [dateOfInterest]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dateOfInterest, networkStatus.connected]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (selectedFireShape?.mof_fire_centre_name) { @@ -96,41 +114,34 @@ const App = () => { }, [selectedFireShape, fireCenters]); useEffect(() => { - const doiISODate = dateOfInterest.toISODate(); - if ( - !isNull(runDatetime) && - !isNull(doiISODate) && - !isUndefined(runDatetime) && - !isUndefined(fireCenter) && - !isNull(fireCenter) && - !isNull(runType) - ) { - dispatch( - fetchFireCentreTPIStats( - fireCenter.name, - runType, - doiISODate, - runDatetime - ) - ); - dispatch( - fetchFireCentreHFIFuelStats( - fireCenter.name, - runType, - doiISODate, - runDatetime - ) - ); + if (!isNil(runParameters)) { + const hfiFilesToKeep: string[] = []; + for (const value of Object.values(runParameters)) { + const pmtilesCache = new PMTilesCache(Filesystem); + pmtilesCache.loadHFIPMTiles( + DateTime.fromISO(value.for_date), + value.run_type, + DateTime.fromISO(value.run_datetime), + "hfi.pmtiles" + ); + hfiFilesToKeep.push( + pmtilesCache.getHFIFileName( + value.for_date, + value.run_type, + value.run_datetime, + "hfi.pmtiles" + ) + ); + } + clearStaleHFIPMTiles(Filesystem, hfiFilesToKeep); } - }, [fireCenter, runDatetime]); // eslint-disable-line react-hooks/exhaustive-deps + }, [runParameters]); useEffect(() => { - const doiISODate = dateOfInterest.toISODate(); - if (!isNull(doiISODate) && !isNull(runType)) { - dispatch(fetchFireShapeAreas(runType, runDatetime, doiISODate)); - dispatch(fetchProvincialSummary(runType, runDatetime, doiISODate)); + if (!isNil(runParameter)) { + dispatch(fetchAndCacheData()); } - }, [runDatetime]); // eslint-disable-line react-hooks/exhaustive-deps + }, [runParameter, dispatch]); // start/stop watching location based on tab and app state useEffect(() => { diff --git a/mobile/asa-go/src/api/fbaAPI.ts b/mobile/asa-go/src/api/fbaAPI.ts index 569cdbfc52..e4df215a0e 100644 --- a/mobile/asa-go/src/api/fbaAPI.ts +++ b/mobile/asa-go/src/api/fbaAPI.ts @@ -34,8 +34,8 @@ export interface AdvisoryCriticalHours { } export interface AdvisoryMinWindStats { - threshold: HfiThreshold - min_wind_speed?: number + threshold: HfiThreshold; + min_wind_speed?: number; } export interface FireZoneFuelStats { @@ -81,9 +81,12 @@ export interface FireZoneTPIStats { upper_slope_tpi?: number; } -export interface FireCentreTPIResponse { - fire_centre_name: string - firezone_tpi_stats: FireZoneTPIStats[] +export interface TPIResponse { + firezone_tpi_stats: FireZoneTPIStats[]; +} + +export interface FireCentreTPIResponse extends TPIResponse { + fire_centre_name: string; } export interface FireShapeAreaListResponse { @@ -114,8 +117,8 @@ export interface HfiThreshold { } export interface FireZoneHFIStats { - min_wind_stats: AdvisoryMinWindStats[] - fuel_area_stats: FireZoneFuelStats[] + min_wind_stats: AdvisoryMinWindStats[]; + fuel_area_stats: FireZoneFuelStats[]; } export interface FuelType { @@ -130,13 +133,25 @@ export interface FireCentreHFIStats { }; } +export interface FireZoneHFIStatsDictionary { + [fire_zone_id: number]: FireZoneHFIStats; +} + +export interface HFIStatsResponse { + zone_data: FireZoneHFIStatsDictionary; +} + export interface RunParameter { - for_date: string - run_datetime: string - run_type: RunType + for_date: string; + run_datetime: string; + run_type: RunType; +} + +export interface RunParametersResponse { + [key: string]: RunParameter; } -const ASA_GO_API_PREFIX = "fba" +const ASA_GO_API_PREFIX = "fba"; export async function getFBAFireCenters(): Promise { const url = `${ASA_GO_API_PREFIX}/fire-centers`; @@ -150,7 +165,9 @@ export async function getFireShapeAreas( run_datetime: string, for_date: string ): Promise { - const url = `${ASA_GO_API_PREFIX}/fire-shape-areas/${run_type.toLowerCase()}/${encodeURI(run_datetime)}/${for_date}`; + const url = `${ASA_GO_API_PREFIX}/fire-shape-areas/${run_type.toLowerCase()}/${encodeURI( + run_datetime + )}/${for_date}`; const { data } = await axios.get(url); return data; } @@ -161,17 +178,30 @@ export async function getProvincialSummary( run_datetime: string, for_date: string ): Promise { - const url = `${ASA_GO_API_PREFIX}/provincial-summary/${run_type.toLowerCase()}/${encodeURI(run_datetime)}/${for_date}`; + const url = `${ASA_GO_API_PREFIX}/provincial-summary/${run_type.toLowerCase()}/${encodeURI( + run_datetime + )}/${for_date}`; const { data } = await axios.get(url); return data; } -export async function getMostRecentRunParameter(forDate: string): Promise { +export async function getMostRecentRunParameter( + forDate: string +): Promise { const url = `${ASA_GO_API_PREFIX}/latest-sfms-run-datetime/${forDate}`; const { data } = await axios.get(url); return data.run_parameter; } +export async function getMostRecentRunParameters( + startDate: string, + endDate: string +): Promise { + const url = `${ASA_GO_API_PREFIX}/latest-sfms-run-parameters/${startDate}/${endDate}`; + const { data } = await axios.get(url); + return data.run_parameters; +} + export async function getFireCentreHFIStats( run_type: RunType, for_date: string, @@ -183,6 +213,16 @@ export async function getFireCentreHFIStats( return data; } +export async function getHFIStats( + run_type: RunType, + for_date: string, + run_datetime: string +): Promise { + const url = `${ASA_GO_API_PREFIX}/hfi-stats/${run_type.toLowerCase()}/${for_date}/${run_datetime}`; + const { data } = await axios.get(url); + return data; +} + export async function getFireZoneElevationInfo( fire_zone_id: number, run_type: RunType, @@ -203,4 +243,14 @@ export async function getFireCentreTPIStats( const url = `${ASA_GO_API_PREFIX}/fire-centre-tpi-stats/${run_type.toLowerCase()}/${run_datetime}/${for_date}/${fire_centre_name}`; const { data } = await axios.get(url); return data; -} \ No newline at end of file +} + +export async function getTPIStats( + run_type: RunType, + run_datetime: string, + for_date: string +): Promise { + const url = `${ASA_GO_API_PREFIX}/tpi-stats/${run_type.toLowerCase()}/${run_datetime}/${for_date}`; + const { data } = await axios.get(url); + return data; +} diff --git a/mobile/asa-go/src/components/AuthWrapper.test.tsx b/mobile/asa-go/src/components/AuthWrapper.test.tsx index 42872d6c1f..961ebdc89c 100644 --- a/mobile/asa-go/src/components/AuthWrapper.test.tsx +++ b/mobile/asa-go/src/components/AuthWrapper.test.tsx @@ -50,7 +50,7 @@ describe("AuthWrapper", () => { expect(screen.getByText("Protected")).toBeInTheDocument(); }); - it("renders login button when unauthenticated and not authenticating", () => { + it("renders children when not authenticated and offline", () => { vi.spyOn(capacitor.Capacitor, "getPlatform").mockReturnValue("web"); vi.spyOn(selectors, "selectAuthentication").mockReturnValue({ isAuthenticated: false, @@ -60,6 +60,28 @@ describe("AuthWrapper", () => { idToken: undefined, token: "test-token", }); + vi.spyOn(selectors, "selectNetworkStatus").mockReturnValue({ + networkStatus: { connected: false, connectionType: "wifi" }, + }); + + renderWithProviders(); + + expect(screen.getByText("Protected")).toBeInTheDocument(); + }); + + it("renders login button when online, unauthenticated and not authenticating", () => { + vi.spyOn(capacitor.Capacitor, "getPlatform").mockReturnValue("web"); + vi.spyOn(selectors, "selectAuthentication").mockReturnValue({ + isAuthenticated: false, + authenticating: false, + error: null, + tokenRefreshed: false, + idToken: undefined, + token: "test-token", + }); + vi.spyOn(selectors, "selectNetworkStatus").mockReturnValue({ + networkStatus: { connected: true, connectionType: "wifi" }, + }); renderWithProviders(); @@ -76,6 +98,9 @@ describe("AuthWrapper", () => { idToken: undefined, token: "test-token", }); + vi.spyOn(selectors, "selectNetworkStatus").mockReturnValue({ + networkStatus: { connected: true, connectionType: "wifi" }, + }); renderWithProviders(); @@ -94,6 +119,9 @@ describe("AuthWrapper", () => { idToken: undefined, token: "test-token", }); + vi.spyOn(selectors, "selectNetworkStatus").mockReturnValue({ + networkStatus: { connected: true, connectionType: "wifi" }, + }); renderWithProviders(); @@ -110,6 +138,9 @@ describe("AuthWrapper", () => { idToken: undefined, token: "test-token", }); + vi.spyOn(selectors, "selectNetworkStatus").mockReturnValue({ + networkStatus: { connected: true, connectionType: "wifi" }, + }); renderWithProviders(); diff --git a/mobile/asa-go/src/components/AuthWrapper.tsx b/mobile/asa-go/src/components/AuthWrapper.tsx index eb3099bde7..8690383cf9 100644 --- a/mobile/asa-go/src/components/AuthWrapper.tsx +++ b/mobile/asa-go/src/components/AuthWrapper.tsx @@ -1,7 +1,7 @@ import AsaIcon from "@/assets/asa-go-transparent.png"; import AppDescription from "@/components/AppDescription"; import LoginButton from "@/components/LoginButton"; -import { selectAuthentication } from "@/store"; +import { selectAuthentication, selectNetworkStatus } from "@/store"; import { Box, CircularProgress, Typography, useTheme } from "@mui/material"; import { isNull } from "lodash"; import React from "react"; @@ -15,8 +15,9 @@ const AuthWrapper = ({ children }: Props) => { const theme = useTheme(); const { isAuthenticated, authenticating, error } = useSelector(selectAuthentication); + const { networkStatus } = useSelector(selectNetworkStatus); - if (isAuthenticated) { + if (isAuthenticated || !networkStatus.connected) { return {children}; } diff --git a/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx b/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx index bb79a86f88..6ad7d56d5b 100644 --- a/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx +++ b/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { DateTime } from "luxon"; import { MAP_BUTTON_GREY } from "@/theme"; import { BORDER_RADIUS, BUTTON_HEIGHT } from "@/components/MapIconButton"; +import { today } from "@/utils/dataSliceUtils"; interface TodayTomorrowSwitchProps { border?: boolean; @@ -46,12 +47,10 @@ const TodayTomorrowSwitch = ({ setDate, }: TodayTomorrowSwitchProps) => { const borderStyle = border ? `1px solid ${MAP_BUTTON_GREY}` : "none"; - const [value, setValue] = useState( - date.day === DateTime.now().day ? 0 : 1 - ); + const [value, setValue] = useState(date.day === today.day ? 0 : 1); useEffect(() => { - setValue(date.day === DateTime.now().day ? 0 : 1); + setValue(date.day === today.day ? 0 : 1); }, [date]); const handleDayChange = (newValue: number) => { diff --git a/mobile/asa-go/src/components/map/ASAGoMap.tsx b/mobile/asa-go/src/components/map/ASAGoMap.tsx index 122b00d9d7..fc66d08105 100644 --- a/mobile/asa-go/src/components/map/ASAGoMap.tsx +++ b/mobile/asa-go/src/components/map/ASAGoMap.tsx @@ -23,6 +23,8 @@ import { fireShapeStyler, } from "@/featureStylers"; import { fireZoneExtentsMap } from "@/fireZoneUnitExtents"; +import { useFireShapeAreasForDate } from "@/hooks/dataHooks"; +import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { BASEMAP_LAYER_NAME, createBasemapLayer, @@ -33,14 +35,8 @@ import { ZONE_STATUS_LAYER_NAME, } from "@/layerDefinitions"; import { startWatchingLocation } from "@/slices/geolocationSlice"; -import { - AppDispatch, - selectFireShapeAreas, - selectGeolocation, - selectNetworkStatus, - selectRunParameter, -} from "@/store"; import { NavPanel } from "@/utils/constants"; +import { AppDispatch, selectGeolocation, selectNetworkStatus } from "@/store"; import { PMTilesCache } from "@/utils/pmtilesCache"; import { PMTilesFileVectorSource } from "@/utils/pmtilesVectorSource"; import { Filesystem } from "@capacitor/filesystem"; @@ -105,8 +101,10 @@ const ASAGoMap = ({ // selectors & hooks const { position, error, loading } = useSelector(selectGeolocation); const { networkStatus } = useSelector(selectNetworkStatus); - const { runDatetime, runType } = useSelector(selectRunParameter); - const { fireShapeAreas } = useSelector(selectFireShapeAreas); + + // hooks + const fireShapeAreas = useFireShapeAreasForDate(date); + const runParameter = useRunParameterForDate(date); // state const [map, setMap] = useState(null); @@ -518,20 +516,23 @@ const ASAGoMap = ({ (async () => { let hfiLayer: VectorTileLayer | null = null; - if (!isNull(runType) && !isNull(runDatetime)) { + if ( + !isNil(runParameter?.run_type) && + !isNil(runParameter?.run_datetime) + ) { hfiLayer = await createHFILayer( { filename: "hfi.pmtiles", for_date: date, - run_type: runType, - run_date: DateTime.fromISO(runDatetime), + run_type: runParameter.run_type, + run_date: DateTime.fromISO(runParameter.run_datetime), }, layerVisibility[HFI_LAYER_NAME] ); } replaceMapLayer(HFI_LAYER_NAME, hfiLayer); })(); - }, [map, runType, runDatetime, date, layerVisibility, replaceMapLayer]); + }, [map, runParameter, date, layerVisibility, replaceMapLayer]); const handlePopupClose = () => { popup.setPosition(undefined); diff --git a/mobile/asa-go/src/components/map/asaGoMap.test.tsx b/mobile/asa-go/src/components/map/asaGoMap.test.tsx index 23fa27ffe5..e7e51c0bc2 100644 --- a/mobile/asa-go/src/components/map/asaGoMap.test.tsx +++ b/mobile/asa-go/src/components/map/asaGoMap.test.tsx @@ -10,7 +10,6 @@ import { createLayerMock, } from "@/testUtils"; import { geolocationInitialState } from "@/slices/geolocationSlice"; -import { RunType } from "@/api/fbaAPI"; import * as mapView from "@/components/map/mapView"; vi.mock("@capacitor/filesystem", () => ({ @@ -51,7 +50,7 @@ vi.mock("@/layerDefinitions", async () => { }; }); -import { createHFILayer, HFI_LAYER_NAME } from "@/layerDefinitions"; +import { HFI_LAYER_NAME } from "@/layerDefinitions"; describe("ASAGoMap", () => { beforeAll(() => { @@ -119,62 +118,8 @@ describe("ASAGoMap", () => { expect(locationButton).not.toBeDisabled(); }); - it("calls createHFILayer when date, runType, or runDatetime changes", async () => { - const runParameter = { - forDate: "2024-12-15", - runDatetime: "2024-12-15T15:00:00Z", - runType: RunType.FORECAST, - loading: false, - error: null, - }; - const store = createTestStore({ - runParameter: runParameter, - }); - - const { rerender } = render( - - - - ); - - // initial call - expect(createHFILayer).toHaveBeenCalledTimes(1); - expect(createHFILayer).toHaveBeenCalledWith( - expect.objectContaining({ - filename: "hfi.pmtiles", - for_date: DateTime.fromISO("2024-12-15"), - run_type: RunType.FORECAST, - run_date: DateTime.fromISO("2024-12-15T15:00:00Z"), - }), - true - ); - - store.dispatch({ - type: "runParameter/getRunParameterSuccess", - payload: { - forDate: "2024-12-16", - runDateTime: "2024-12-16T23:00:00Z", - runType: RunType.FORECAST, - }, - }); - rerender( - - - - ); - expect(createHFILayer).toHaveBeenCalledWith( - expect.objectContaining({ - filename: "hfi.pmtiles", - for_date: DateTime.fromISO("2024-12-16"), - run_type: RunType.FORECAST, - run_date: DateTime.fromISO("2024-12-16T23:00:00Z"), - }), - true - ); - }); it("renders the layer switcher button and legend on click", async () => { const store = createTestStore(); - const { getByTestId } = render( @@ -264,6 +209,7 @@ describe("ASAGoMap", () => { await import("@/components/map/layerVisibility"), "setDefaultLayerVisibility" ); + const mockToggleLayersRef = { hfiVectorLayer: null, }; diff --git a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.test.tsx b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.test.tsx index 2e68ed5f58..3572d0b1ee 100644 --- a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.test.tsx +++ b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.test.tsx @@ -5,6 +5,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { useSelector } from "react-redux"; import FireZoneUnitSummary from "@/components/profile/FireZoneUnitSummary"; import { FireCenter, FireShape, FireZoneTPIStats } from "@/api/fbaAPI"; +import { DateTime } from "luxon"; // Mock child components vi.mock("@/components/profile/FuelSummary", () => ({ @@ -29,13 +30,10 @@ vi.mock("@/components/profile/ElevationStatus", () => ({ ), })); -// Mock redux selectors -vi.mock("@/slices/fireCentreHFIFuelStatsSlice", () => ({ - selectFilteredFireCentreHFIFuelStats: vi.fn(), -})); - -vi.mock("@/store", () => ({ - selectFireCentreTPIStats: vi.fn(), +// Mock hooks +vi.mock("@/hooks/datahooks", () => ({ + useFilteredHFIStatsForDate: vi.fn(), + useTPIStatsForDate: vi.fn(), })); // Mock theme @@ -55,6 +53,7 @@ vi.mock("react-redux", async () => { }); describe("FireZoneUnitSummary", () => { + const testDate = DateTime.fromISO("2025-08-25"); const mockFireCenter: FireCenter = { id: 1, name: "Test Fire Center", @@ -106,6 +105,7 @@ describe("FireZoneUnitSummary", () => { ); @@ -118,6 +118,7 @@ describe("FireZoneUnitSummary", () => { ); @@ -130,6 +131,7 @@ describe("FireZoneUnitSummary", () => { ); @@ -143,6 +145,7 @@ describe("FireZoneUnitSummary", () => { ); @@ -156,6 +159,7 @@ describe("FireZoneUnitSummary", () => { ); @@ -169,6 +173,7 @@ describe("FireZoneUnitSummary", () => { ); @@ -186,6 +191,7 @@ describe("FireZoneUnitSummary", () => { ); @@ -198,6 +204,7 @@ describe("FireZoneUnitSummary", () => { ); diff --git a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx index f8c750d534..ff1bb9b1c6 100644 --- a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx +++ b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx @@ -1,42 +1,44 @@ +import ElevationStatus from "@/components/profile/ElevationStatus"; +import FuelSummary from "@/components/profile/FuelSummary"; +import { + useFilteredHFIStatsForDate, + useTPIStatsForDate, +} from "@/hooks/dataHooks"; +import { hasRequiredFields } from "@/utils/profileUtils"; import { Box, Grid2 as Grid, Typography } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import { FireCenter, FireShape } from "api/fbaAPI"; import { isNil, isUndefined } from "lodash"; +import { DateTime } from "luxon"; import React, { useMemo } from "react"; -import FuelSummary from "@/components/profile/FuelSummary"; -import { useTheme } from "@mui/material/styles"; -import { useSelector } from "react-redux"; -import { selectFilteredFireCentreHFIFuelStats } from "@/slices/fireCentreHFIFuelStatsSlice"; -import ElevationStatus from "@/components/profile/ElevationStatus"; -import { selectFireCentreTPIStats } from "@/store"; -import { hasRequiredFields } from "@/utils/profileUtils"; interface FireZoneUnitSummaryProps { + date: DateTime; selectedFireCenter: FireCenter | undefined; selectedFireZoneUnit: FireShape | undefined; } const FireZoneUnitSummary = ({ + date, selectedFireCenter, selectedFireZoneUnit, }: FireZoneUnitSummaryProps) => { const theme = useTheme(); - // selectors - const filteredFireCentreHFIFuelStats = useSelector( - selectFilteredFireCentreHFIFuelStats - ); - const { fireCentreTPIStats } = useSelector(selectFireCentreTPIStats); + // hooks + const filteredFireZoneUnitHFIStats = useFilteredHFIStatsForDate(date); + const fireCentreTPIStats = useTPIStatsForDate(date); // derived state const hfiFuelStats = useMemo(() => { if (selectedFireCenter) { - return filteredFireCentreHFIFuelStats?.[selectedFireCenter?.name]; + return filteredFireZoneUnitHFIStats; } - }, [filteredFireCentreHFIFuelStats, selectedFireCenter]); + }, [filteredFireZoneUnitHFIStats, selectedFireCenter]); const fireZoneTPIStats = useMemo(() => { if (selectedFireCenter && !isNil(fireCentreTPIStats)) { - const tpiStatsArray = fireCentreTPIStats?.firezone_tpi_stats; + const tpiStatsArray = fireCentreTPIStats; return tpiStatsArray ? tpiStatsArray.find( (stats) => diff --git a/mobile/asa-go/src/components/profile/Profile.tsx b/mobile/asa-go/src/components/profile/Profile.tsx index 1d3b385190..48e7ca1f2f 100644 --- a/mobile/asa-go/src/components/profile/Profile.tsx +++ b/mobile/asa-go/src/components/profile/Profile.tsx @@ -116,10 +116,12 @@ const Profile = ({ selectedFireCenter={selectedFireCenter} selectedFireZoneUnit={selectedFireZoneUnit} setSelectedFireZoneUnit={setSelectedFireZoneUnit} + date={date} > )} diff --git a/mobile/asa-go/src/components/report/Advisory.tsx b/mobile/asa-go/src/components/report/Advisory.tsx index 252d338a08..493d480bb3 100644 --- a/mobile/asa-go/src/components/report/Advisory.tsx +++ b/mobile/asa-go/src/components/report/Advisory.tsx @@ -111,11 +111,13 @@ const Advisory = ({ selectedFireCenter={selectedFireCenter} selectedFireZoneUnit={selectedFireZoneUnit} setSelectedFireZoneUnit={setSelectedFireZoneUnit} + date={date} > diff --git a/mobile/asa-go/src/components/report/AdvisoryText.tsx b/mobile/asa-go/src/components/report/AdvisoryText.tsx index b33ed369cf..4c7153354f 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -5,9 +5,12 @@ import { FireZoneHFIStats, } from "@/api/fbaAPI"; import DefaultText from "@/components/report/DefaultText"; -import { selectFilteredFireCentreHFIFuelStats } from "@/slices/fireCentreHFIFuelStatsSlice"; -import { selectProvincialSummary } from "@/slices/provincialSummarySlice"; -import { selectForDate, selectRunDatetime } from "@/slices/runParameterSlice"; +import { + useFilteredHFIStatsForDate, + useProvincialSummaryForDate, +} from "@/hooks/dataHooks"; +import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; +import { today } from "@/utils/dataSliceUtils"; import { getTopFuelsByArea, getTopFuelsByProportion, @@ -21,10 +24,9 @@ import { getMinStartAndMaxEndTime, } from "@/utils/criticalHoursStartEndTime"; import { Box, styled, Typography, useTheme } from "@mui/material"; -import { isEmpty, isNil, isNull, isUndefined } from "lodash"; +import { isEmpty, isNil, isUndefined } from "lodash"; import { DateTime } from "luxon"; import { useMemo } from "react"; -import { useSelector } from "react-redux"; export const SerifTypography = styled(Typography)({ fontSize: "1.2rem", @@ -35,49 +37,49 @@ interface AdvisoryTextProps { advisoryThreshold: number; selectedFireCenter: FireCenter | undefined; selectedFireZoneUnit: FireShape | undefined; + date: DateTime; } const AdvisoryText = ({ advisoryThreshold, selectedFireCenter, selectedFireZoneUnit, + date, }: AdvisoryTextProps) => { const theme = useTheme(); - // selectors - const provincialSummary = useSelector(selectProvincialSummary); - const filteredFireCentreHFIFuelStats = useSelector( - selectFilteredFireCentreHFIFuelStats - ); - const forDate = useSelector(selectForDate); - const runDatetime = useSelector(selectRunDatetime); + // hooks + const provincialSummary = useProvincialSummaryForDate(date); + const filteredFireZoneUnitHFIStats = useFilteredHFIStatsForDate(date); + const runParameter = useRunParameterForDate(date); // derived state const selectedFilteredZoneUnitFuelStats = useMemo(() => { if ( - isUndefined(filteredFireCentreHFIFuelStats) || - isEmpty(filteredFireCentreHFIFuelStats) || - isUndefined(selectedFireCenter) || - isUndefined(selectedFireZoneUnit) + isUndefined(filteredFireZoneUnitHFIStats) || + isEmpty(filteredFireZoneUnitHFIStats) || + isUndefined(selectedFireZoneUnit) || + isNil(runParameter) ) { return { fuel_area_stats: [], min_wind_stats: [] }; } - const allFilteredZoneUnitFuelStats = - filteredFireCentreHFIFuelStats[selectedFireCenter.name]; return ( - allFilteredZoneUnitFuelStats?.[selectedFireZoneUnit.fire_shape_id] ?? { + filteredFireZoneUnitHFIStats?.[selectedFireZoneUnit.fire_shape_id] ?? { fuel_area_stats: [], min_wind_stats: [], } ); - }, [filteredFireCentreHFIFuelStats, selectedFireZoneUnit]); + }, [filteredFireZoneUnitHFIStats, selectedFireZoneUnit]); const selectedFireZoneUnitTopFuels = useMemo(() => { - if (isNull(forDate)) { + if (isNil(runParameter?.for_date)) { return []; } - return getTopFuelsByArea(selectedFilteredZoneUnitFuelStats, forDate); - }, [selectedFilteredZoneUnitFuelStats, forDate]); + return getTopFuelsByArea( + selectedFilteredZoneUnitFuelStats, + DateTime.fromISO(runParameter.for_date) + ); + }, [selectedFilteredZoneUnitFuelStats, runParameter]); const highHFIFuelsByProportion = useMemo(() => { return getTopFuelsByProportion( @@ -95,7 +97,7 @@ const AdvisoryText = ({ const zoneStatus = useMemo(() => { if (selectedFireCenter) { - const fireCenterSummary = provincialSummary[selectedFireCenter.name]; + const fireCenterSummary = provincialSummary?.[selectedFireCenter.name]; const fireZoneUnitInfos = fireCenterSummary?.filter( (fc) => fc.fire_shape_id === selectedFireZoneUnit?.fire_shape_id ); @@ -105,7 +107,12 @@ const AdvisoryText = ({ ); return zoneStatus; } - }, [selectedFireCenter, selectedFireZoneUnit, provincialSummary]); + }, [ + advisoryThreshold, + selectedFireCenter, + selectedFireZoneUnit, + provincialSummary, + ]); const getCommaSeparatedString = (array: string[]): string => { // Slice off the last two items and join then with ' and ' to create a new string. Then take the first n-2 items and @@ -230,10 +237,13 @@ const AdvisoryText = ({ const renderAdvisoryText = () => { const zoneTitle = `${selectedFireZoneUnit?.mof_fire_zone_name}:\n\n`; - const forToday = forDate!.toISODate() === DateTime.now().toISODate(); + const forToday = runParameter?.for_date === today.toISODate(); const displayForDate = forToday ? "today" - : forDate!.toLocaleString({ month: "short", day: "numeric" }); + : DateTime.fromISO(runParameter!.for_date).toLocaleString({ + month: "short", + day: "numeric", + }); const minWindSpeedText = getZoneMinWindStatsText( selectedFilteredZoneUnitFuelStats.min_wind_stats ); @@ -291,12 +301,14 @@ const AdvisoryText = ({ )} - {runDatetime?.isValid && ( + {runParameter?.run_datetime && ( - {`Issued on ${runDatetime?.toLocaleString( + {`Issued on ${DateTime.fromISO( + runParameter.run_datetime + )?.toLocaleString( DateTime.DATETIME_FULL )} for ${displayForDate}.\n\n`} @@ -359,7 +371,9 @@ const AdvisoryText = ({ backgroundColor: "white", }} > - {!selectedFireCenter || !runDatetime?.isValid || !selectedFireZoneUnit + {!selectedFireCenter || + isNil(runParameter?.run_datetime) || + !selectedFireZoneUnit ? renderDefaultMessage() : renderAdvisoryText()} diff --git a/mobile/asa-go/src/components/report/FireZoneUnitTabs.tsx b/mobile/asa-go/src/components/report/FireZoneUnitTabs.tsx index 4a3532662e..b7958f336f 100644 --- a/mobile/asa-go/src/components/report/FireZoneUnitTabs.tsx +++ b/mobile/asa-go/src/components/report/FireZoneUnitTabs.tsx @@ -4,6 +4,7 @@ import { calculateStatusColour } from "@/utils/calculateZoneStatus"; import { Tab, Tabs } from "@mui/material"; import { Box } from "@mui/system"; import { isEmpty } from "lodash"; +import { DateTime } from "luxon"; import { useEffect, useState } from "react"; interface FireZoneUnitTabsProps { @@ -14,6 +15,7 @@ interface FireZoneUnitTabsProps { React.SetStateAction >; children: React.ReactNode; + date: DateTime; } const FireZoneUnitTabs = ({ @@ -22,9 +24,13 @@ const FireZoneUnitTabs = ({ selectedFireCenter, selectedFireZoneUnit, setSelectedFireZoneUnit, + date, }: FireZoneUnitTabsProps) => { const [tabNumber, setTabNumber] = useState(0); - const sortedGroupedFireZoneUnits = useFireCentreDetails(selectedFireCenter); + const sortedGroupedFireZoneUnits = useFireCentreDetails( + selectedFireCenter, + date + ); const getTabFireShape = (tabNumber: number): FireShape | undefined => { if (sortedGroupedFireZoneUnits.length > 0) { diff --git a/mobile/asa-go/src/components/report/advisoryText.test.tsx b/mobile/asa-go/src/components/report/advisoryText.test.tsx index 95377b2e78..d35b82d1df 100644 --- a/mobile/asa-go/src/components/report/advisoryText.test.tsx +++ b/mobile/asa-go/src/components/report/advisoryText.test.tsx @@ -2,30 +2,44 @@ import { FireCenter, FireShape, FireShapeAreaDetail, + FireZoneHFIStatsDictionary, + RunParameter, RunType, } from "@/api/fbaAPI"; import AdvisoryText from "@/components/report/AdvisoryText"; -import fireCentreHFIFuelStatsSlice, { - FireCentreHFIFuelStatsState, - initialState as fuelStatsInitialState, - getFireCentreHFIFuelStatsSuccess, -} from "@/slices/fireCentreHFIFuelStatsSlice"; -import provincialSummarySlice, { - ProvincialSummaryState, - initialState as provSummaryInitialState, -} from "@/slices/provincialSummarySlice"; -import runParameterSlice, { - initialState as runParameterInitialState, - RunParameterState, -} from "@/slices/runParameterSlice"; +import dataSlice, { + DataState, + initialState as dataInitialState, +} from "@/slices/dataSlice"; +import runParametersSlice, { + initialState as runParametersInitialState, + RunParametersState, +} from "@/slices/runParametersSlice"; import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { render, screen, waitFor } from "@testing-library/react"; import { cloneDeep } from "lodash"; import { DateTime } from "luxon"; import { Provider } from "react-redux"; +import { Mock, vi } from "vitest"; + +// Mock hooks +vi.mock("@/hooks/useRunParameterForDate", () => ({ + useRunParameterForDate: vi.fn(), +})); +vi.mock(import("@/hooks/dataHooks"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFilteredHFIStatsForDate: vi.fn(), + }; +}); +import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; +import { useFilteredHFIStatsForDate } from "@/hooks/dataHooks"; +import { PST_UTC_OFFSET } from "@/utils/constants"; const advisoryThreshold = 20; const TEST_FOR_DATE = "2025-07-14"; +const TEST_FOR_DATE_LUXON = DateTime.fromISO(TEST_FOR_DATE); const TEST_RUN_DATETIME = "2025-07-13"; const EXPECTED_FOR_DATE = DateTime.fromISO(TEST_FOR_DATE).toLocaleString({ month: "short", @@ -35,6 +49,12 @@ const EXPECTED_RUN_DATETIME = DateTime.fromISO( TEST_RUN_DATETIME ).toLocaleString(DateTime.DATETIME_FULL); +const testRunParameter: RunParameter = { + for_date: TEST_FOR_DATE, + run_datetime: TEST_RUN_DATETIME, + run_type: RunType.FORECAST, +}; + const mockFireCenter: FireCenter = { id: 1, name: "Cariboo Fire Centre", @@ -118,136 +138,153 @@ const noAdvisoryDetails: FireShapeAreaDetail[] = [ }, ]; -const initialHFIFuelStats = { - "Cariboo Fire Centre": { - "20": { - fuel_area_stats: [ - { - fuel_type: { - fuel_type_id: 2, - fuel_type_code: "C-2", - description: "Boreal Spruce", - }, - threshold: { - id: 1, - name: "advisory", - description: "4000 < hfi < 10000", - }, - critical_hours: { - start_time: 9, - end_time: 13, - }, - area: 4000000000, - fuel_area: 8000000000, +const mockFireZoneHFIStatsDictionary: FireZoneHFIStatsDictionary = { + "20": { + fuel_area_stats: [ + { + fuel_type: { + fuel_type_id: 2, + fuel_type_code: "C-2", + description: "Boreal Spruce", }, - ], - min_wind_stats: [ - { - threshold: { - id: 1, - name: "advisory", - description: "4000 < hfi < 10000", - }, - min_wind_speed: 1, + threshold: { + id: 1, + name: "advisory", + description: "4000 < hfi < 10000", }, - { - threshold: { - id: 2, - name: "warning", - description: "hfi > 1000", - }, - min_wind_speed: 1, + critical_hours: { + start_time: 9, + end_time: 13, }, - ], - }, + area: 4000000000, + fuel_area: 8000000000, + }, + ], + min_wind_stats: [ + { + threshold: { + id: 1, + name: "advisory", + description: "4000 < hfi < 10000", + }, + min_wind_speed: 1, + }, + { + threshold: { + id: 2, + name: "warning", + description: "hfi > 1000", + }, + min_wind_speed: 1, + }, + ], }, }; -const missingCriticalHoursStartFuelStatsState: FireCentreHFIFuelStatsState = { - error: null, - fireCentreHFIFuelStats: { - "Prince George Fire Centre": { - "25": { - fuel_area_stats: [ - { - fuel_type: { - fuel_type_id: 2, - fuel_type_code: "C-2", - description: "Boreal Spruce", - }, - threshold: { - id: 1, - name: "advisory", - description: "4000 < hfi < 10000", +const missingCriticalHoursStartDataState: Partial = { + hfiStats: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: { + 25: { + fuel_area_stats: [ + { + fuel_type: { + fuel_type_id: 2, + fuel_type_code: "C-2", + description: "Boreal Spruce", + }, + threshold: { + id: 1, + name: "advisory", + description: "4000 < hfi < 10000", + }, + critical_hours: { + start_time: undefined, + end_time: 13, + }, + area: 4000, + fuel_area: 8000, }, - critical_hours: { - start_time: undefined, - end_time: 13, - }, - area: 4000, - fuel_area: 8000, - }, - ], - min_wind_stats: [], + ], + min_wind_stats: [], + }, }, }, }, }; -const missingCriticalHoursEndFuelStatsState: FireCentreHFIFuelStatsState = { - error: null, - fireCentreHFIFuelStats: { - "Prince George Fire Centre": { - "25": { - fuel_area_stats: [ - { - fuel_type: { - fuel_type_id: 2, - fuel_type_code: "C-2", - description: "Boreal Spruce", - }, - threshold: { - id: 1, - name: "advisory", - description: "4000 < hfi < 10000", - }, - critical_hours: { - start_time: 9, - end_time: undefined, +const missingCriticalHoursEndDataState: Partial = { + hfiStats: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: { + "25": { + fuel_area_stats: [ + { + fuel_type: { + fuel_type_id: 2, + fuel_type_code: "C-2", + description: "Boreal Spruce", + }, + threshold: { + id: 1, + name: "advisory", + description: "4000 < hfi < 10000", + }, + critical_hours: { + start_time: 9, + end_time: undefined, + }, + area: 4000, + fuel_area: 8000, }, - area: 4000, - fuel_area: 8000, - }, - ], - min_wind_stats: [], + ], + min_wind_stats: [], + }, }, }, }, }; -const runParameterTestState = { - ...runParameterInitialState, - forDate: TEST_FOR_DATE, - runDatetime: TEST_RUN_DATETIME, - runType: RunType.FORECAST, +const runParametersTestState = { + ...runParametersInitialState, + [TEST_FOR_DATE]: { + for_date: TEST_FOR_DATE, + run_datetime: TEST_RUN_DATETIME, + run_type: RunType.FORECAST, + }, +}; + +const runParametersTestStateNoRunDateTimeState = { + ...runParametersInitialState, + [TEST_FOR_DATE]: { + for_date: TEST_FOR_DATE, + run_type: RunType.FORECAST, + }, +}; + +const runParametersTestStateNoForDateState = { + ...runParametersInitialState, + [TEST_FOR_DATE]: { + run_datetime: TEST_RUN_DATETIME, + run_type: RunType.FORECAST, + }, }; const buildTestStore = ( - provincialSummaryInitialState: ProvincialSummaryState, - runParameterInitialState: RunParameterState, - fuelStatsInitialState?: FireCentreHFIFuelStatsState + dataInitialState: DataState, + runParametersInitialState: RunParametersState ) => { const rootReducer = combineReducers({ - provincialSummary: provincialSummarySlice, - runParameter: runParameterSlice, - fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice, + data: dataSlice, + runParameters: runParametersSlice, }); const testStore = configureStore({ reducer: rootReducer, preloadedState: { - provincialSummary: provincialSummaryInitialState, - runParameter: runParameterInitialState, - fireCentreHFIFuelStats: fuelStatsInitialState, + data: dataInitialState, + runParameters: runParametersInitialState, }, }); return testStore; @@ -256,19 +293,29 @@ const buildTestStore = ( describe("AdvisoryText", () => { const testStore = buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: advisoryDetails, + ...dataInitialState, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: advisoryDetails, + }, + }, }, - runParameterInitialState + runParametersInitialState ); const getInitialStore = () => buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: warningDetails, + ...dataInitialState, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: warningDetails, + }, + }, }, - runParameterTestState + runParametersTestState ); const assertInitialState = () => { @@ -286,7 +333,7 @@ describe("AdvisoryText", () => { ).toBeInTheDocument(); expect( screen.queryByTestId("advisory-message-wind-speed") - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); expect( screen.queryByTestId("advisory-message-slash") ).not.toBeInTheDocument(); @@ -302,6 +349,7 @@ describe("AdvisoryText", () => { selectedFireCenter={undefined} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -316,6 +364,7 @@ describe("AdvisoryText", () => { selectedFireCenter={undefined} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -332,6 +381,7 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={undefined} + date={TEST_FOR_DATE_LUXON} /> ); @@ -344,10 +394,15 @@ describe("AdvisoryText", () => { it("should render no data message when the runDatetime is null", () => { const testStore = buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: advisoryDetails, + ...dataInitialState, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: advisoryDetails, + }, + }, }, - { ...runParameterInitialState, forDate: TEST_FOR_DATE } + runParametersTestStateNoRunDateTimeState ); const { getByTestId, queryByTestId } = render( @@ -355,6 +410,7 @@ describe("AdvisoryText", () => { selectedFireCenter={mockFireCenter} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -367,13 +423,15 @@ describe("AdvisoryText", () => { it("should render no data message when the forDate is null", () => { const testStore = buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: advisoryDetails, + ...dataInitialState, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: advisoryDetails, + }, + }, }, - { - ...runParameterInitialState, - runDatetime: TEST_RUN_DATETIME, - } + runParametersTestStateNoForDateState ); const { getByTestId, queryByTestId } = render( @@ -381,6 +439,7 @@ describe("AdvisoryText", () => { selectedFireCenter={mockFireCenter} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -391,6 +450,10 @@ describe("AdvisoryText", () => { }); it("should include fuel stats when their fuel area is above the 100 * 2000m * 2000m threshold", async () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + (useFilteredHFIStatsForDate as Mock).mockReturnValue( + mockFireZoneHFIStatsDictionary + ); const store = getInitialStore(); render( @@ -398,27 +461,31 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); + screen.debug(); assertInitialState(); - store.dispatch(getFireCentreHFIFuelStatsSuccess(initialHFIFuelStats)); await waitFor(() => expect( screen.queryByTestId("advisory-message-warning") ).toBeInTheDocument() ); + await waitFor(() => expect( screen.queryByTestId("advisory-message-warning") ).toHaveTextContent( - initialHFIFuelStats["Cariboo Fire Centre"][20].fuel_area_stats[0] - .fuel_type.fuel_type_code + mockFireZoneHFIStatsDictionary[20].fuel_area_stats[0].fuel_type + .fuel_type_code ) ); }); it("should not include fuel stats when their fuel area is below the 100 * 2000m * 2000m threshold", async () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + (useFilteredHFIStatsForDate as Mock).mockReturnValue({}); const store = getInitialStore(); render( @@ -426,16 +493,10 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); - assertInitialState(); - const smallAreaStats = cloneDeep(initialHFIFuelStats); - smallAreaStats["Cariboo Fire Centre"][20].fuel_area_stats[0].area = 10; - smallAreaStats[ - "Cariboo Fire Centre" - ][20].fuel_area_stats[0].fuel_area = 100; - store.dispatch(getFireCentreHFIFuelStatsSuccess(smallAreaStats)); await waitFor(() => expect( @@ -446,8 +507,8 @@ describe("AdvisoryText", () => { expect( screen.queryByTestId("advisory-message-warning") ).not.toHaveTextContent( - initialHFIFuelStats["Cariboo Fire Centre"][20].fuel_area_stats[0] - .fuel_type.fuel_type_code + mockFireZoneHFIStatsDictionary[20].fuel_area_stats[0].fuel_type + .fuel_type_code ) ); }); @@ -459,6 +520,7 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); @@ -470,12 +532,23 @@ describe("AdvisoryText", () => { }); it("should render forDate as 'today' when forDate parameter matches today's date", () => { + const todayRunParameter = cloneDeep(testRunParameter); + (useRunParameterForDate as Mock).mockReturnValue({ + ...todayRunParameter, + for_date: DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`).toISODate(), + }); + (useFilteredHFIStatsForDate as Mock).mockReturnValue({}); const store = buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: noAdvisoryDetails, + ...dataInitialState, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: advisoryDetails, + }, + }, }, - { ...runParameterTestState, forDate: DateTime.now().toISODate() } + runParametersTestStateNoForDateState ); const { queryByTestId } = render( @@ -483,6 +556,7 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); @@ -494,12 +568,19 @@ describe("AdvisoryText", () => { }); it("should render a no advisories message when there are no advisories/warnings", () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + (useFilteredHFIStatsForDate as Mock).mockReturnValue({}); const noAdvisoryStore = buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: noAdvisoryDetails, + ...dataInitialState, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: noAdvisoryDetails, + }, + }, }, - runParameterTestState + runParametersTestState ); const { queryByTestId } = render( @@ -507,6 +588,7 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); @@ -531,19 +613,14 @@ describe("AdvisoryText", () => { }); it("should render warning status", () => { - const warningStore = buildTestStore( - { - ...provSummaryInitialState, - fireShapeAreaDetails: warningDetails, - }, - runParameterTestState - ); + const warningStore = getInitialStore(); const { queryByTestId } = render( ); @@ -569,19 +646,13 @@ describe("AdvisoryText", () => { }); it("should render advisory status", () => { - const advisoryStore = buildTestStore( - { - ...provSummaryInitialState, - fireShapeAreaDetails: advisoryDetails, - }, - runParameterTestState - ); const { queryByTestId } = render( - + ); @@ -607,6 +678,10 @@ describe("AdvisoryText", () => { }); it("should render wind speed text and early fire behaviour text when fire zone unit is selected, based on wind speed & critical hours data", async () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + (useFilteredHFIStatsForDate as Mock).mockReturnValue( + mockFireZoneHFIStatsDictionary + ); const store = getInitialStore(); render( @@ -614,11 +689,10 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); - assertInitialState(); - store.dispatch(getFireCentreHFIFuelStatsSuccess(initialHFIFuelStats)); await waitFor(() => expect( screen.queryByTestId("advisory-message-wind-speed") @@ -632,6 +706,10 @@ describe("AdvisoryText", () => { }); it("should render early advisory text and overnight burning text when critical hours go into the next day and start before 12", async () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + const filteredStatsNextDay = cloneDeep(mockFireZoneHFIStatsDictionary); + filteredStatsNextDay[20].fuel_area_stats[0].critical_hours.end_time = 5; + (useFilteredHFIStatsForDate as Mock).mockReturnValue(filteredStatsNextDay); const store = getInitialStore(); render( @@ -639,17 +717,10 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); - assertInitialState(); - - const overnightStats = cloneDeep(initialHFIFuelStats); - overnightStats[ - "Cariboo Fire Centre" - ][20].fuel_area_stats[0].critical_hours.end_time = 5; - - store.dispatch(getFireCentreHFIFuelStatsSuccess(overnightStats)); await waitFor(() => expect(screen.queryByTestId("early-advisory-text")).toBeInTheDocument() ); @@ -664,6 +735,11 @@ describe("AdvisoryText", () => { }); it("should render only overnight burning text when critical hours go into the next day and start after 12", async () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + const filteredStatsNextDay = cloneDeep(mockFireZoneHFIStatsDictionary); + filteredStatsNextDay[20].fuel_area_stats[0].critical_hours.end_time = 5; + filteredStatsNextDay[20].fuel_area_stats[0].critical_hours.start_time = 13; + (useFilteredHFIStatsForDate as Mock).mockReturnValue(filteredStatsNextDay); const store = getInitialStore(); render( @@ -671,20 +747,10 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); - assertInitialState(); - - const overnightStats = cloneDeep(initialHFIFuelStats); - overnightStats[ - "Cariboo Fire Centre" - ][20].fuel_area_stats[0].critical_hours.end_time = 5; - overnightStats[ - "Cariboo Fire Centre" - ][20].fuel_area_stats[0].critical_hours.start_time = 13; - - store.dispatch(getFireCentreHFIFuelStatsSuccess(overnightStats)); await waitFor(async () => expect( screen.queryByTestId("early-advisory-text") @@ -703,15 +769,21 @@ describe("AdvisoryText", () => { it("should render critical hours missing message when critical hours start time is missing", () => { const store = buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: advisoryDetails, + ...dataInitialState, + hfiStats: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: missingCriticalHoursStartDataState, + }, + }, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: advisoryDetails, + }, + }, }, - runParameterTestState, - { - ...fuelStatsInitialState, - fireCentreHFIFuelStats: - missingCriticalHoursStartFuelStatsState.fireCentreHFIFuelStats, - } + runParametersTestState ); const { queryByTestId } = render( @@ -719,6 +791,7 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockAdvisoryFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); @@ -733,15 +806,21 @@ describe("AdvisoryText", () => { it("should render critical hours missing message when critical hours end time is missing", () => { const store = buildTestStore( { - ...provSummaryInitialState, - fireShapeAreaDetails: advisoryDetails, + ...dataInitialState, + hfiStats: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: missingCriticalHoursEndDataState, + }, + }, + provincialSummaries: { + [TEST_FOR_DATE]: { + runParameter: testRunParameter, + data: advisoryDetails, + }, + }, }, - runParameterTestState, - { - ...fuelStatsInitialState, - fireCentreHFIFuelStats: - missingCriticalHoursEndFuelStatsState.fireCentreHFIFuelStats, - } + runParametersTestState ); const { queryByTestId } = render( @@ -749,6 +828,7 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockAdvisoryFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); @@ -761,6 +841,10 @@ describe("AdvisoryText", () => { }); it("should not render slash warning when critical hours duration is less than 12 hours", async () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + (useFilteredHFIStatsForDate as Mock).mockReturnValue( + mockFireZoneHFIStatsDictionary + ); const store = getInitialStore(); render( @@ -768,11 +852,10 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); - assertInitialState(); - store.dispatch(getFireCentreHFIFuelStatsSuccess(initialHFIFuelStats)); await waitFor(() => expect( screen.queryByTestId("advisory-message-slash") @@ -781,6 +864,14 @@ describe("AdvisoryText", () => { }); it("should render slash warning when critical hours duration is greater than 12 hours", async () => { + (useRunParameterForDate as Mock).mockReturnValue(testRunParameter); + const filteredCriticalHoursStats = cloneDeep( + mockFireZoneHFIStatsDictionary + ); + filteredCriticalHoursStats[20].fuel_area_stats[0].critical_hours.end_time = 22; + (useFilteredHFIStatsForDate as Mock).mockReturnValue( + filteredCriticalHoursStats + ); const store = getInitialStore(); render( @@ -788,17 +879,10 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); - assertInitialState(); - - const newHFIFuelStats = cloneDeep(initialHFIFuelStats); - newHFIFuelStats[ - "Cariboo Fire Centre" - ][20].fuel_area_stats[0].critical_hours.end_time = 22; - - store.dispatch(getFireCentreHFIFuelStatsSuccess(newHFIFuelStats)); await waitFor(() => expect(screen.queryByTestId("advisory-message-slash")).toBeInTheDocument() ); diff --git a/mobile/asa-go/src/components/todayTomorrowSwitch.test.tsx b/mobile/asa-go/src/components/todayTomorrowSwitch.test.tsx index b4e3bab824..44c8a17430 100644 --- a/mobile/asa-go/src/components/todayTomorrowSwitch.test.tsx +++ b/mobile/asa-go/src/components/todayTomorrowSwitch.test.tsx @@ -2,11 +2,12 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { DateTime } from "luxon"; import { describe, expect, it, vi } from "vitest"; import TodayTomorrowSwitch from "./TodayTomorrowSwitch"; +import { PST_UTC_OFFSET } from "@/utils/constants"; describe("TodayTomorrowSwitch", () => { it("renders both buttons", () => { const mockSetDate = vi.fn(); - const today = DateTime.now(); + const today = DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`); render(); @@ -16,7 +17,7 @@ describe("TodayTomorrowSwitch", () => { it("disables the NOW button when date is today", () => { const mockSetDate = vi.fn(); - const today = DateTime.now(); + const today = DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`); render(); @@ -26,7 +27,9 @@ describe("TodayTomorrowSwitch", () => { it("disables the TMR button when date is tomorrow", () => { const mockSetDate = vi.fn(); - const tomorrow = DateTime.now().plus({ days: 1 }); + const tomorrow = DateTime.now() + .plus({ days: 1 }) + .setZone(`UTC${PST_UTC_OFFSET}`); render(); @@ -36,7 +39,7 @@ describe("TodayTomorrowSwitch", () => { it("clicking TMR updates the date to tomorrow", () => { const mockSetDate = vi.fn(); - const today = DateTime.now(); + const today = DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`); render(); @@ -48,7 +51,9 @@ describe("TodayTomorrowSwitch", () => { it("clicking NOW updates the date to today", () => { const mockSetDate = vi.fn(); - const tomorrow = DateTime.now().plus({ days: 1 }); + const tomorrow = DateTime.now() + .plus({ days: 1 }) + .setZone(`UTC${PST_UTC_OFFSET}`); render(); @@ -58,28 +63,27 @@ describe("TodayTomorrowSwitch", () => { expect(mockSetDate).toHaveBeenCalledWith(tomorrow.plus({ day: -1 })); }); + it("updates internal state when date prop changes", () => { + const today = DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`); + const tomorrow = today.plus({ day: 1 }); + const setDateMock = vi.fn(); -it("updates internal state when date prop changes", () => { - const today = DateTime.now(); - const tomorrow = today.plus({ day: 1 }); - const setDateMock = vi.fn(); + const { rerender } = render( + + ); - const { rerender } = render( - - ); + // Initially, NOW button should be disabled (today selected) + const nowButton = screen.getByRole("button", { name: /NOW/i }); + const tmrButton = screen.getByRole("button", { name: /TMR/i }); - // Initially, NOW button should be disabled (today selected) - const nowButton = screen.getByRole("button", { name: /NOW/i }); - const tmrButton = screen.getByRole("button", { name: /TMR/i }); + expect(nowButton).toBeDisabled(); + expect(tmrButton).not.toBeDisabled(); - expect(nowButton).toBeDisabled(); - expect(tmrButton).not.toBeDisabled(); + // Re-render with tomorrow's date + rerender(); - // Re-render with tomorrow's date - rerender(); - - // NOW should now be enabled, TMR should be disabled - expect(nowButton).not.toBeDisabled(); - expect(tmrButton).toBeDisabled(); -}); + // NOW should now be enabled, TMR should be disabled + expect(nowButton).not.toBeDisabled(); + expect(tmrButton).toBeDisabled(); + }); }); diff --git a/mobile/asa-go/src/hooks/dataHooks.test.tsx b/mobile/asa-go/src/hooks/dataHooks.test.tsx new file mode 100644 index 0000000000..29f1a1fc76 --- /dev/null +++ b/mobile/asa-go/src/hooks/dataHooks.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { DateTime } from "luxon"; +import { + useFilteredHFIStatsForDate, + useFireShapeAreasForDate, + useProvincialSummaryForDate, + useTPIStatsForDate, +} from "@/hooks/dataHooks"; +import { + FireShapeArea, + FireShapeAreaDetail, + FireZoneHFIStatsDictionary, + FireZoneTPIStats, + RunParameter, + RunType, +} from "@/api/fbaAPI"; +import { filterHFIFuelStatsByArea } from "@/utils/hfiStatsUtils"; +import { RootState } from "@/store"; +import { initialState } from "@/slices/dataSlice"; +import { ReactNode } from "react"; + +vi.mock("@/utils/hfiStatsUtils", () => ({ + filterHFIFuelStatsByArea: vi.fn((data) => data), // mock passthrough +})); + +const today = DateTime.now(); +const todayKey = today.toISODate(); + +const mockRunParameter: RunParameter = { + run_type: RunType.FORECAST, + run_datetime: "2025-11-20T00:00:00Z", + for_date: todayKey, +}; + +const createMockStore = (state: Partial) => + configureStore({ + reducer: () => state, + }); + +describe("Custom Hooks", () => { + it("useFilteredHFIStatsForDate returns filtered HFI stats", () => { + const mockHFIStats: FireZoneHFIStatsDictionary = { + 2: { + min_wind_stats: [], + fuel_area_stats: [], + }, + }; + const store = createMockStore({ + data: { + ...initialState, + hfiStats: { + [todayKey]: { runParameter: mockRunParameter, data: mockHFIStats }, + }, + }, + }); + + const { result } = renderHook(() => useFilteredHFIStatsForDate(today), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + expect(result.current).toEqual(mockHFIStats); + expect(filterHFIFuelStatsByArea).toHaveBeenCalledWith(mockHFIStats); + }); + + it("useFilteredHFIStatsForDate returns [] when data is missing", () => { + const store = createMockStore({ data: { ...initialState, hfiStats: {} } }); + + const { result } = renderHook(() => useFilteredHFIStatsForDate(today), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + expect(result.current).toEqual([]); + }); + + it("useFireShapeAreasForDate returns FireShapeAreas", () => { + const mockAreas: FireShapeArea[] = [{ fire_shape_id: 1 } as FireShapeArea]; + const store = createMockStore({ + data: { + ...initialState, + fireShapeAreas: { + [todayKey]: { runParameter: mockRunParameter, data: mockAreas }, + }, + }, + }); + + const { result } = renderHook(() => useFireShapeAreasForDate(today), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + expect(result.current).toEqual(mockAreas); + }); + + it("useProvincialSummaryForDate groups by fire_centre_name", () => { + const mockSummary: FireShapeAreaDetail[] = [ + { fire_shape_id: 1, fire_centre_name: "Centre A" } as FireShapeAreaDetail, + { fire_shape_id: 2, fire_centre_name: "Centre A" } as FireShapeAreaDetail, + ]; + const store = createMockStore({ + data: { + ...initialState, + provincialSummaries: { + [todayKey]: { runParameter: mockRunParameter, data: mockSummary }, + }, + }, + }); + + const { result } = renderHook(() => useProvincialSummaryForDate(today), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + expect(result.current).toHaveProperty("Centre A"); + expect(result.current?.["Centre A"].length).toBe(2); + }); + + it("useTPIStatsForDate returns TPI stats", () => { + const mockTPIStats: FireZoneTPIStats[] = [ + { fire_zone_id: 1 } as FireZoneTPIStats, + ]; + const store = createMockStore({ + data: { + ...initialState, + tpiStats: { + [todayKey]: { runParameter: mockRunParameter, data: mockTPIStats }, + }, + }, + }); + + const { result } = renderHook(() => useTPIStatsForDate(today), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + expect(result.current).toEqual(mockTPIStats); + }); + + it("returns empty array or undefined when forDate is nil", () => { + const store = createMockStore({ + data: { + ...initialState, + hfiStats: {}, + fireShapeAreas: {}, + provincialSummaries: {}, + tpiStats: {}, + }, + }); + + const { result: hfiResult } = renderHook( + () => useFilteredHFIStatsForDate(null as unknown as DateTime), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + } + ); + expect(hfiResult.current).toEqual([]); + + const { result: fireShapeResult } = renderHook( + () => useFireShapeAreasForDate(null as unknown as DateTime), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + } + ); + expect(fireShapeResult.current).toEqual([]); + + const { result: provincialResult } = renderHook( + () => useProvincialSummaryForDate(null as unknown as DateTime), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + } + ); + expect(provincialResult.current).toBeUndefined(); + + const { result: tpiResult } = renderHook( + () => useTPIStatsForDate(null as unknown as DateTime), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + } + ); + expect(tpiResult.current).toEqual([]); + }); +}); diff --git a/mobile/asa-go/src/hooks/dataHooks.ts b/mobile/asa-go/src/hooks/dataHooks.ts new file mode 100644 index 0000000000..dd30c46946 --- /dev/null +++ b/mobile/asa-go/src/hooks/dataHooks.ts @@ -0,0 +1,94 @@ +import { FireShapeArea, FireShapeAreaDetail, FireZoneHFIStatsDictionary, FireZoneTPIStats } from "@/api/fbaAPI"; +import { selectFireShapeAreas, selectHFIStats, selectProvincialSummaries, selectTPIStats } from "@/store"; +import { filterHFIFuelStatsByArea } from "@/utils/hfiStatsUtils"; +import { Dictionary, groupBy, isNil } from "lodash"; +import { DateTime } from "luxon"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +/** + * A hook for retrieving the FireZoneHFIStatsDictionary for the provided forDate. + * @param forDate + * @returns FireZoneHFIStatsDictionary] + */ +export const useFilteredHFIStatsForDate = ( + forDate: DateTime +): FireZoneHFIStatsDictionary => { + const hfiStats = useSelector(selectHFIStats); + return useMemo(() => { + const forDateString = forDate?.toISODate(); + if ( + isNil(forDate) || + isNil(forDateString) || + isNil(hfiStats?.[forDateString]?.data) + ) { + return []; + } + const hfiStatsForDate = hfiStats[forDateString].data; + const filteredHFIStatsForDate = filterHFIFuelStatsByArea(hfiStatsForDate) + return filteredHFIStatsForDate; + }, [hfiStats, forDate]); +}; + +/** + * A hook for retrieving the FireShapeAreas for the provided forDate. + * @param forDate + * @returns FireShapeArea[] + */ +export const useFireShapeAreasForDate = ( + forDate: DateTime +): FireShapeArea[] => { + const fireShapeAreas = useSelector(selectFireShapeAreas); + return useMemo(() => { + const forDateString = forDate?.toISODate(); + if ( + isNil(forDate) || + isNil(forDateString) || + isNil(fireShapeAreas?.[forDateString]?.data) + ) { + return []; + } + const fireShapeAreasForDate = fireShapeAreas[forDateString].data; + return fireShapeAreasForDate; + }, [fireShapeAreas, forDate]); +}; + +/** + * A hook for retrieving the provincial summary for the provided forDate. + * @param forDate + * @returns FireShapeAreDetail[] + */ +export const useProvincialSummaryForDate = ( + forDate: DateTime +): Dictionary | undefined => { + const provincialSummaries = useSelector(selectProvincialSummaries); + return useMemo(() => { + const forDateString = forDate?.toISODate() + if (isNil(forDate) || isNil(forDateString) || isNil(provincialSummaries?.[forDateString]?.data)) { + return undefined; + } + const provincialSummary = provincialSummaries[forDateString].data + return groupBy(provincialSummary, "fire_centre_name") + }, [provincialSummaries, forDate]); +}; + +/** + * A hook for retrieving the FireZoneTPIStats for the provided forDate. + * @param forDate + * @returns FireZoneTPIStats[] + */ +export const useTPIStatsForDate = (forDate: DateTime): FireZoneTPIStats[] => { + const tpiStats = useSelector(selectTPIStats); + return useMemo(() => { + const forDateString = forDate?.toISODate(); + if ( + isNil(forDate) || + isNil(forDateString) || + isNil(tpiStats?.[forDateString]?.data) + ) { + return []; + } + const tpiStatsForDate = tpiStats[forDateString].data; + return tpiStatsForDate; + }, [tpiStats, forDate]); +}; \ No newline at end of file diff --git a/mobile/asa-go/src/hooks/useFireCentreDetails.test.tsx b/mobile/asa-go/src/hooks/useFireCentreDetails.test.tsx index 948151b3da..04e11b87a5 100644 --- a/mobile/asa-go/src/hooks/useFireCentreDetails.test.tsx +++ b/mobile/asa-go/src/hooks/useFireCentreDetails.test.tsx @@ -2,15 +2,16 @@ import { renderHook } from '@testing-library/react'; import { useFireCentreDetails } from './useFireCentreDetails'; import { FireCenter, FireShapeAreaDetail } from 'api/fbaAPI'; -import { selectProvincialSummary } from '@/slices/provincialSummarySlice'; -import { Provider } from 'react-redux'; -import { configureStore, Store } from '@reduxjs/toolkit'; -import { Mock, vi } from 'vitest'; -import React from 'react'; +import { Provider } from "react-redux"; +import { configureStore, Store } from "@reduxjs/toolkit"; +import { Mock, vi } from "vitest"; +import React from "react"; +import { useProvincialSummaryForDate } from "@/hooks/dataHooks"; +import { DateTime } from "luxon"; -// Mock the selector -vi.mock('@/slices/provincialSummarySlice', () => ({ - selectProvincialSummary: vi.fn(), +// Mock the useProvincialSummaryForDate hook +vi.mock("@/hooks/dataHooks", () => ({ + useProvincialSummaryForDate: vi.fn(), })); // Helper to wrap hook with Redux provider @@ -20,78 +21,88 @@ const createWrapper = (store: Store) => { ); }; -describe('useFireCentreDetails', () => { - it('returns grouped and sorted fire shape details for a selected fire center', () => { +describe("useFireCentreDetails", () => { + it("returns grouped and sorted fire shape details for a selected fire center", () => { + const testDate = DateTime.fromISO("2025-08-25"); const mockFireCenter: FireCenter = { id: 1, - name: 'Test Centre', + name: "Test Centre", stations: [], }; const mockSummary: Record = { - 'Test Centre': [ + "Test Centre": [ { fire_shape_id: 2, - fire_shape_name: 'Zone B', - fire_centre_name: 'Test Centre', + fire_shape_name: "Zone B", + fire_centre_name: "Test Centre", combustible_area: 100, elevated_hfi_percentage: 10, }, { fire_shape_id: 1, - fire_shape_name: 'Zone A', - fire_centre_name: 'Test Centre', + fire_shape_name: "Zone A", + fire_centre_name: "Test Centre", combustible_area: 200, elevated_hfi_percentage: 20, }, { fire_shape_id: 1, - fire_shape_name: 'Zone A', - fire_centre_name: 'Test Centre', + fire_shape_name: "Zone A", + fire_centre_name: "Test Centre", combustible_area: 150, elevated_hfi_percentage: 15, }, ], }; - // Mock selector return value - ((selectProvincialSummary as unknown) as Mock).mockReturnValue(mockSummary); + // Mock hook return value + (useProvincialSummaryForDate as unknown as Mock).mockReturnValue( + mockSummary + ); const store = configureStore({ reducer: () => ({}), // dummy reducer preloadedState: {}, }); - const { result } = renderHook(() => useFireCentreDetails(mockFireCenter), { - wrapper: createWrapper(store), - }); + const { result } = renderHook( + () => useFireCentreDetails(mockFireCenter, testDate), + { + wrapper: createWrapper(store), + } + ); expect(result.current).toEqual([ { fire_shape_id: 1, - fire_shape_name: 'Zone A', - fire_centre_name: 'Test Centre', + fire_shape_name: "Zone A", + fire_centre_name: "Test Centre", fireShapeDetails: [ - mockSummary['Test Centre'][1], - mockSummary['Test Centre'][2], + mockSummary["Test Centre"][1], + mockSummary["Test Centre"][2], ], }, { fire_shape_id: 2, - fire_shape_name: 'Zone B', - fire_centre_name: 'Test Centre', - fireShapeDetails: [mockSummary['Test Centre'][0]], + fire_shape_name: "Zone B", + fire_centre_name: "Test Centre", + fireShapeDetails: [mockSummary["Test Centre"][0]], }, ]); }); - it('returns an empty array if no fire center is selected', () => { - ((selectProvincialSummary as unknown) as Mock).mockReturnValue({}); + it("returns an empty array if no fire center is selected", () => { + const testDate = DateTime.fromISO("2025-08-25"); + (useProvincialSummaryForDate as unknown as Mock).mockReturnValue({}); const store = configureStore({ reducer: () => ({}) }); - const { result } = renderHook(() => useFireCentreDetails(undefined), { - wrapper: createWrapper(store), - }); + const { result } = renderHook( + () => useFireCentreDetails(undefined, testDate), + { + wrapper: createWrapper(store), + } + ); expect(result.current).toEqual([]); }); diff --git a/mobile/asa-go/src/hooks/useFireCentreDetails.ts b/mobile/asa-go/src/hooks/useFireCentreDetails.ts index a3a184355e..1eea6815c4 100644 --- a/mobile/asa-go/src/hooks/useFireCentreDetails.ts +++ b/mobile/asa-go/src/hooks/useFireCentreDetails.ts @@ -1,8 +1,8 @@ -import { selectProvincialSummary } from "@/slices/provincialSummarySlice"; +import { useProvincialSummaryForDate } from "@/hooks/dataHooks"; import { FireCenter, FireShapeAreaDetail } from "api/fbaAPI"; import { groupBy } from "lodash"; +import { DateTime } from "luxon"; import { useMemo } from "react"; -import { useSelector } from "react-redux"; export interface GroupedFireZoneUnitDetails { fire_shape_id: number; @@ -19,14 +19,15 @@ export interface GroupedFireZoneUnitDetails { * @returns */ export const useFireCentreDetails = ( - selectedFireCenter: FireCenter | undefined + selectedFireCenter: FireCenter | undefined, + forDate: DateTime ): GroupedFireZoneUnitDetails[] => { - const provincialSummary = useSelector(selectProvincialSummary); + const provincialSummary = useProvincialSummaryForDate(forDate) return useMemo(() => { if (!selectedFireCenter) return []; - const fireCenterSummary = provincialSummary[selectedFireCenter.name] || []; + const fireCenterSummary = provincialSummary?.[selectedFireCenter.name] || []; const groupedFireZoneUnits = groupBy(fireCenterSummary, "fire_shape_id"); return Object.values(groupedFireZoneUnits) diff --git a/mobile/asa-go/src/hooks/useRunParameterForDate.ts b/mobile/asa-go/src/hooks/useRunParameterForDate.ts new file mode 100644 index 0000000000..e52f532354 --- /dev/null +++ b/mobile/asa-go/src/hooks/useRunParameterForDate.ts @@ -0,0 +1,24 @@ +import { RunParameter } from "@/api/fbaAPI"; +import { selectRunParameters } from "@/store"; +import { isNil } from "lodash"; +import { DateTime } from "luxon"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +/** + * A hook for retrieving the run parameters for the provided forDate. + * @param forDate + * @returns + */ +export const useRunParameterForDate = ( + forDate: DateTime +): RunParameter | undefined => { + const runParameters = useSelector(selectRunParameters); + return useMemo(() => { + const forDateString = forDate.toISODate() + if (isNil(forDate) || isNil(forDateString) || isNil(runParameters)) { + return undefined; + } + return runParameters[forDateString] + }, [runParameters, forDate]); +}; \ No newline at end of file diff --git a/mobile/asa-go/src/rootReducer.ts b/mobile/asa-go/src/rootReducer.ts index f9e0a534ef..d706957b34 100644 --- a/mobile/asa-go/src/rootReducer.ts +++ b/mobile/asa-go/src/rootReducer.ts @@ -1,24 +1,16 @@ -import provincialSummarySlice from "@/slices/provincialSummarySlice"; -import fireZoneElevationInfoSlice from "@/slices/fireZoneElevationInfoSlice"; -import fireShapeAreasSlice from "@/slices/fireZoneAreasSlice"; -import fireCentreTPIStatsSlice from "@/slices/fireCentreTPIStatsSlice"; -import fireCentreHFIFuelStatsSlice from "@/slices/fireCentreHFIFuelStatsSlice"; +import authenticateSlice from "@/slices/authenticationSlice"; +import dataSlice from "@/slices/dataSlice"; import fireCentersSlice from "@/slices/fireCentersSlice"; -import networkStatusSlice from "@/slices/networkStatusSlice"; -import runParameterSlice from "@/slices/runParameterSlice"; import geolocationSlice from "@/slices/geolocationSlice"; -import authenticateSlice from "@/slices/authenticationSlice"; +import networkStatusSlice from "@/slices/networkStatusSlice"; +import runParametersSlice from "@/slices/runParametersSlice"; import { combineReducers } from "@reduxjs/toolkit"; export const rootReducer = combineReducers({ fireCenters: fireCentersSlice, - provincialSummary: provincialSummarySlice, - fireZoneElevationInfo: fireZoneElevationInfoSlice, - fireShapeAreas: fireShapeAreasSlice, - fireCentreTPIStats: fireCentreTPIStatsSlice, - fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice, networkStatus: networkStatusSlice, geolocation: geolocationSlice, - runParameter: runParameterSlice, + runParameters: runParametersSlice, authentication: authenticateSlice, + data: dataSlice, }); diff --git a/mobile/asa-go/src/slices/dataSlice.test.ts b/mobile/asa-go/src/slices/dataSlice.test.ts new file mode 100644 index 0000000000..2664abc744 --- /dev/null +++ b/mobile/asa-go/src/slices/dataSlice.test.ts @@ -0,0 +1,496 @@ +vi.mock("api/fbaAPI", async () => { + const actual = await vi.importActual( + "api/fbaAPI" + ); + return { + ...actual, + getMostRecentRunParameters: vi.fn(), + }; +}); + +vi.mock("@/utils/storage", () => ({ + writeToFileSystem: vi.fn(), + readFromFilesystem: vi.fn(), + FIRE_CENTERS_KEY: "fireCenters", + FIRE_SHAPE_AREAS_KEY: "fireShapeAreas", + HFI_STATS_KEY: "hfiStats", + PROVINCIAL_SUMMARY_KEY: "provincialSummary", + RUN_PARAMETERS_CACHE_KEY: "runParameters", + TPI_STATS_KEY: "tpiStats", +})); + +vi.mock("@/utils/dataSliceUtils", async () => { + const actual = await vi.importActual< + typeof import("@/utils/dataSliceUtils") + >("@/utils/dataSliceUtils"); + return { + ...actual, + fetchFireShapeAreas: vi.fn(), + fetchHFIStats: vi.fn(), + fetchProvincialSummaries: vi.fn(), + fetchTpiStats: vi.fn(), + }; +}); + +import reducer, { + DataState, + fetchAndCacheData, + getDataFailed, + getDataStart, + getDataSuccess, + initialState, +} from "@/slices/dataSlice"; +import { + fetchFireShapeAreas, + fetchHFIStats, + fetchProvincialSummaries, + fetchTpiStats, +} from "@/utils/dataSliceUtils"; +import { initialState as runParametersInitialState } from "@/slices/runParametersSlice"; +import { createTestStore } from "@/testUtils"; +import { + CacheableData, + FIRE_SHAPE_AREAS_KEY, + HFI_STATS_KEY, + PROVINCIAL_SUMMARY_KEY, + readFromFilesystem, + TPI_STATS_KEY, +} from "@/utils/storage"; +import { + AdvisoryCriticalHours, + AdvisoryMinWindStats, + FireShapeArea, + FireShapeAreaDetail, + FireZoneFuelStats, + FireZoneHFIStatsDictionary, + FireZoneTPIStats, + FuelType, + HfiThreshold, + RunParameter, + RunType, +} from "api/fbaAPI"; +import { DateTime } from "luxon"; +import { describe, expect, it, Mock, vi } from "vitest"; + +const yesterday = DateTime.now().plus({ days: -1 }).toISODate(); +const today = DateTime.now().toISODate(); +const tomorrow = DateTime.now().plus({ days: 1 }).toISODate(); + +const mockYesterdayRunParameter = { + for_date: yesterday, + run_datetime: "2025-08-27T08:00:00Z", + run_type: RunType.FORECAST, +}; + +const mockTodayRunParameter = { + for_date: today, + run_datetime: "2025-08-27T08:00:00Z", + run_type: RunType.FORECAST, +}; + +const mockTomorrowRunParameter = { + for_date: tomorrow, + run_datetime: "2025-08-28T08:00:00Z", + run_type: RunType.FORECAST, +}; + +const mockStaleRunParameters: { [key: string]: RunParameter } = { + [yesterday]: mockYesterdayRunParameter, + [today]: mockTodayRunParameter, +}; + +const mockRunParameters: { [key: string]: RunParameter } = { + [today]: mockTodayRunParameter, + [tomorrow]: mockTomorrowRunParameter, +}; + +const mockFireShapeArea: FireShapeArea = { + fire_shape_id: 1, + threshold: 1, + combustible_area: 10, + elevated_hfi_area: 5, + elevated_hfi_percentage: 50, +}; + +const mockStaleCacheableFireshapeAreas: CacheableData = { + [yesterday]: { + runParameter: mockYesterdayRunParameter, + data: [mockFireShapeArea], + }, + [today]: { + runParameter: mockTodayRunParameter, + data: [mockFireShapeArea], + }, +}; + +const mockCacheableFireshapeAreas: CacheableData = { + [today]: { + runParameter: mockTodayRunParameter, + data: [mockFireShapeArea], + }, + [tomorrow]: { + runParameter: mockTomorrowRunParameter, + data: [mockFireShapeArea], + }, +}; + +const mockFireShapeAreaDetail: FireShapeAreaDetail = { + ...mockFireShapeArea, + fire_shape_name: "test_fire_zone_unit", + fire_centre_name: "test_fire_centre", +}; + +const mockStaleCacheableProvincialSummaries: CacheableData< + FireShapeAreaDetail[] +> = { + [yesterday]: { + runParameter: mockYesterdayRunParameter, + data: [mockFireShapeAreaDetail], + }, + [today]: { + runParameter: mockTodayRunParameter, + data: [mockFireShapeAreaDetail], + }, +}; + +const mockCacheableProvincialSummaries: CacheableData = { + [today]: { + runParameter: mockTodayRunParameter, + data: [mockFireShapeAreaDetail], + }, + [tomorrow]: { + runParameter: mockTomorrowRunParameter, + data: [mockFireShapeAreaDetail], + }, +}; + +const mockFireZoneTPIStats: FireZoneTPIStats = { + fire_zone_id: 1, + valley_bottom_hfi: 5, + valley_bottom_tpi: 5, + mid_slope_hfi: 10, + mid_slope_tpi: 10, + upper_slope_hfi: 15, + upper_slope_tpi: 15, +}; + +const mockStaleCacheableFireZoneTPIStats: CacheableData = { + [yesterday]: { + runParameter: mockYesterdayRunParameter, + data: [mockFireZoneTPIStats], + }, + [today]: { + runParameter: mockTodayRunParameter, + data: [mockFireZoneTPIStats], + }, +}; + +const mockCacheableFireZoneTPIStats: CacheableData = { + [today]: { + runParameter: mockTodayRunParameter, + data: [mockFireZoneTPIStats], + }, + [tomorrow]: { + runParameter: mockTomorrowRunParameter, + data: [mockFireZoneTPIStats], + }, +}; + +const mockHFIThreshold: HfiThreshold = { + id: 1, + name: "test", + description: "test description", +}; + +const mockAdvisoryMinWindStats: AdvisoryMinWindStats = { + threshold: mockHFIThreshold, + min_wind_speed: 5, +}; + +const mockFuelType: FuelType = { + fuel_type_id: 1, + fuel_type_code: "C-3", + description: "tree", +}; + +const mockAdvisoryCriticalHours: AdvisoryCriticalHours = { + start_time: 10, + end_time: 20, +}; + +const mockFireZoneFuelStats: FireZoneFuelStats = { + fuel_type: mockFuelType, + threshold: mockHFIThreshold, + critical_hours: mockAdvisoryCriticalHours, + area: 5, + fuel_area: 10, +}; + +const mockFireZoneHFIStats: FireZoneHFIStats = { + min_wind_stats: [mockAdvisoryMinWindStats], + fuel_area_stats: [mockFireZoneFuelStats], +}; + +export interface FireZoneHFIStats { + min_wind_stats: AdvisoryMinWindStats[]; + fuel_area_stats: FireZoneFuelStats[]; +} + +const mockStaleCacheableHFIStats: CacheableData = { + [yesterday]: { + runParameter: mockYesterdayRunParameter, + data: { + 1: mockFireZoneHFIStats, + }, + }, + [today]: { + runParameter: mockTodayRunParameter, + data: { + 1: mockFireZoneHFIStats, + }, + }, +}; + +const mockCacheableHFIStats: CacheableData = { + [today]: { + runParameter: mockTodayRunParameter, + data: { + 1: mockFireZoneHFIStats, + }, + }, + [tomorrow]: { + runParameter: mockTomorrowRunParameter, + data: { + 1: mockFireZoneHFIStats, + }, + }, +}; + +const mockStaleData = { + lastUpdated: yesterday, + fireShapeAreas: mockStaleCacheableFireshapeAreas, + provincialSummaries: mockStaleCacheableProvincialSummaries, + tpiStats: mockStaleCacheableFireZoneTPIStats, + hfiStats: mockStaleCacheableHFIStats, +}; + +const mockData = { + lastUpdated: today, + fireShapeAreas: mockCacheableFireshapeAreas, + provincialSummaries: mockCacheableProvincialSummaries, + tpiStats: mockCacheableFireZoneTPIStats, + hfiStats: mockCacheableHFIStats, +}; + +export const staleInitialState: DataState = { + loading: false, + error: null, + ...mockStaleData, +}; + +describe("data reducer", () => { + it("should handle getDataStart", () => { + const nextState = reducer(initialState, getDataStart()); + expect(nextState.loading).toBe(true); + expect(nextState.error).toBeNull(); + }); + + it("should handle getDataFailed", () => { + const error = "API error"; + const nextState = reducer(initialState, getDataFailed(error)); + expect(nextState.loading).toBe(false); + expect(nextState.error).toBe(error); + }); + + it("should handle getDataSuccess", () => { + const nextState = reducer(initialState, getDataSuccess({ ...mockData })); + expect(nextState.loading).toBe(false); + expect(nextState.error).toBeNull(); + expect(nextState.lastUpdated).toEqual(today); + expect(nextState.fireShapeAreas).toEqual(mockCacheableFireshapeAreas); + expect(nextState.provincialSummaries).toEqual( + mockCacheableProvincialSummaries + ); + expect(nextState.tpiStats).toEqual(mockCacheableFireZoneTPIStats); + expect(nextState.hfiStats).toEqual(mockCacheableHFIStats); + }); +}); + +describe("fetchAndCacheData thunk", () => { + const mockCacheWithData = () => { + (readFromFilesystem as Mock).mockImplementation((filesystem, key) => { + switch (key) { + case PROVINCIAL_SUMMARY_KEY: + return { + data: mockCacheableProvincialSummaries, + }; + case FIRE_SHAPE_AREAS_KEY: + return { + data: mockCacheableFireshapeAreas, + }; + case TPI_STATS_KEY: + return { + data: mockCacheableFireZoneTPIStats, + }; + case HFI_STATS_KEY: + return { + data: mockCacheableHFIStats, + }; + } + }); + }; + const mockCacheWithNoData = () => { + (readFromFilesystem as Mock).mockImplementation(() => { + return null; + }); + }; + const mockAPIData = () => { + vi.mocked(fetchFireShapeAreas).mockResolvedValue( + mockCacheableFireshapeAreas + ); + vi.mocked(fetchHFIStats).mockResolvedValue(mockCacheableHFIStats); + vi.mocked(fetchProvincialSummaries).mockResolvedValue( + mockCacheableProvincialSummaries + ); + vi.mocked(fetchTpiStats).mockResolvedValue(mockCacheableFireZoneTPIStats); + }; + const testExpectedDataState = (dataState: DataState) => { + expect(dataState.error).toBeNull(); + expect(dataState.fireShapeAreas).toEqual(mockCacheableFireshapeAreas); + expect(dataState.provincialSummaries).toEqual( + mockCacheableProvincialSummaries + ); + expect(dataState.tpiStats).toEqual(mockCacheableFireZoneTPIStats); + expect(dataState.hfiStats).toEqual(mockCacheableHFIStats); + }; + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + it("should dispatch getDataFailed when runParameters is null", async () => { + const store = createTestStore({ + data: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: runParametersInitialState, + }); + await store.dispatch(fetchAndCacheData()); + expect(store.getState().data.error).toMatch(/runParameters can't be null/); + }); + + it("should update state from cache when cache is current and state is empty", async () => { + mockCacheWithData(); + const store = createTestStore({ + data: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockRunParameters, + }, + }); + await store.dispatch(fetchAndCacheData()); + + // API should not be called + expect(fetchFireShapeAreas).not.toHaveBeenCalled(); + expect(fetchHFIStats).not.toHaveBeenCalled(); + expect(fetchProvincialSummaries).not.toHaveBeenCalled(); + expect(fetchTpiStats).not.toHaveBeenCalled(); + + // redux store should be updated with the cached data + const dataState = store.getState().data; + testExpectedDataState(dataState); + }); + it("should update state from cache when cache is current and state is stale", async () => { + mockCacheWithData(); + const store = createTestStore({ + data: { ...staleInitialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockRunParameters, + }, + }); + await store.dispatch(fetchAndCacheData()); + + // Utility functions which call the API should not be called + expect(fetchFireShapeAreas).not.toHaveBeenCalled(); + expect(fetchHFIStats).not.toHaveBeenCalled(); + expect(fetchProvincialSummaries).not.toHaveBeenCalled(); + expect(fetchTpiStats).not.toHaveBeenCalled(); + + // redux store should be updated with the cached data + const dataState = store.getState().data; + testExpectedDataState(dataState); + }); + it("should update state from API calls when cache is empty and app is online", async () => { + mockAPIData(); + mockCacheWithNoData(); + const store = createTestStore({ + data: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockRunParameters, + }, + }); + await store.dispatch(fetchAndCacheData()); + + // Utility functions which call the API should be called + expect(fetchFireShapeAreas).toHaveBeenCalled(); + expect(fetchHFIStats).toHaveBeenCalled(); + expect(fetchProvincialSummaries).toHaveBeenCalled(); + expect(fetchTpiStats).toHaveBeenCalled(); + + // redux store should be updated with the fetched data + const dataState = store.getState().data; + testExpectedDataState(dataState); + }); + it("should update state from API calls when run parameters state is stale", async () => { + mockAPIData(); + mockCacheWithData(); + const store = createTestStore({ + data: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockStaleRunParameters, + }, + }); + await store.dispatch(fetchAndCacheData()); + + // Utility functions which call the API should be called + expect(fetchFireShapeAreas).toHaveBeenCalled(); + expect(fetchHFIStats).toHaveBeenCalled(); + expect(fetchProvincialSummaries).toHaveBeenCalled(); + expect(fetchTpiStats).toHaveBeenCalled(); + + // redux store should be updated with the fetched data + const dataState = store.getState().data; + testExpectedDataState(dataState); + }); + + it("should dispatch getDataFailed when state is stale and app is offline", async () => { + mockCacheWithData(); + const store = createTestStore({ + data: { ...initialState }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockStaleRunParameters, + }, + }); + await store.dispatch(fetchAndCacheData()); + expect(store.getState().data.error).toMatch(/Unable to update data/); + }); +}); diff --git a/mobile/asa-go/src/slices/dataSlice.ts b/mobile/asa-go/src/slices/dataSlice.ts new file mode 100644 index 0000000000..6ade7a20ce --- /dev/null +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -0,0 +1,250 @@ +import { AppThunk } from "@/store"; +import { + dataAreEqual, + fetchFireShapeAreas, + fetchHFIStats, + fetchProvincialSummaries, + fetchTpiStats, + getTodayKey, + getTomorrowKey, + runParametersMatch, + today, +} from "@/utils/dataSliceUtils"; +import { + CacheableData, + CachedData, + FIRE_SHAPE_AREAS_KEY, + HFI_STATS_KEY, + PROVINCIAL_SUMMARY_KEY, + readFromFilesystem, + TPI_STATS_KEY, + writeToFileSystem, +} from "@/utils/storage"; +import { Filesystem } from "@capacitor/filesystem"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { + FireShapeArea, + FireShapeAreaDetail, + FireZoneHFIStatsDictionary, + FireZoneTPIStats, +} from "api/fbaAPI"; +import { isNil } from "lodash"; +import { DateTime } from "luxon"; + +export interface DataState { + loading: boolean; + error: string | null; + lastUpdated: string | null; + fireShapeAreas: CacheableData | null; + provincialSummaries: CacheableData | null; + tpiStats: CacheableData | null; + hfiStats: CacheableData | null; +} + +export const initialState: DataState = { + loading: false, + error: null, + lastUpdated: null, + provincialSummaries: null, + fireShapeAreas: null, + tpiStats: null, + hfiStats: null, +}; + +const dataSlice = createSlice({ + name: "data", + initialState, + reducers: { + getDataStart(state: DataState) { + state.error = null; + state.loading = true; + }, + getDataFailed(state: DataState, action: PayloadAction) { + state.error = action.payload; + state.loading = false; + }, + getDataSuccess( + state: DataState, + action: PayloadAction<{ + lastUpdated: string; + fireShapeAreas: CacheableData | null; + provincialSummaries: CacheableData | null; + tpiStats: CacheableData | null; + hfiStats: CacheableData | null; + }> + ) { + state.error = null; + state.lastUpdated = action.payload.lastUpdated; + state.fireShapeAreas = action.payload.fireShapeAreas; + state.provincialSummaries = action.payload.provincialSummaries; + state.tpiStats = action.payload.tpiStats; + state.hfiStats = action.payload.hfiStats; + state.loading = false; + }, + }, +}); + +export const { getDataStart, getDataFailed, getDataSuccess } = + dataSlice.actions; + +export default dataSlice.reducer; + +export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { + const todayKey = getTodayKey(); + const tomorrowKey = getTomorrowKey(); + const state = getState(); + const runParameters = state.runParameters.runParameters; + let isCurrent = true; // A flag indicating if the cached data and state are current + if (isNil(runParameters)) { + dispatch( + getDataFailed( + "Unable to fetch and cache data; runParameters can't be null." + ) + ); + return; + } + // Grab cached data and check if we have cached data for the run parameters in state, if so, set + // redux state with this data. + const cachedProvincialSummaries = (await readFromFilesystem( + Filesystem, + PROVINCIAL_SUMMARY_KEY + )) as CachedData>; + isCurrent = + isCurrent && + !isNil(cachedProvincialSummaries?.data) && + runParametersMatch( + todayKey, + tomorrowKey, + runParameters, + cachedProvincialSummaries.data + ); + + const cachedFireShapeAreas = (await readFromFilesystem( + Filesystem, + FIRE_SHAPE_AREAS_KEY + )) as CachedData>; + isCurrent = + isCurrent && + !isNil(cachedFireShapeAreas?.data) && + runParametersMatch( + todayKey, + tomorrowKey, + runParameters, + cachedFireShapeAreas.data + ); + + const cachedTPIStats = (await readFromFilesystem( + Filesystem, + TPI_STATS_KEY + )) as CachedData>; + isCurrent = + isCurrent && + !isNil(cachedTPIStats?.data) && + runParametersMatch( + todayKey, + tomorrowKey, + runParameters, + cachedTPIStats.data + ); + + const cachedHFIStats = (await readFromFilesystem( + Filesystem, + HFI_STATS_KEY + )) as CachedData>; + isCurrent = + isCurrent && + !isNil(cachedHFIStats?.data) && + runParametersMatch( + todayKey, + tomorrowKey, + runParameters, + cachedHFIStats.data + ); + + if (isCurrent) { + // No need to fetch new data, compare cached data to state data to see if state update required + const stateProvincialSummaries = state.data.provincialSummaries; + const stateFireShapeAreas = state.data.fireShapeAreas; + const stateTPIStats = state.data.tpiStats; + const stateHFIStats = state.data.hfiStats; + if ( + !dataAreEqual(stateProvincialSummaries, cachedProvincialSummaries.data) && + !dataAreEqual(stateFireShapeAreas, cachedFireShapeAreas.data) && + !dataAreEqual(stateTPIStats, cachedTPIStats.data) && + !dataAreEqual(stateHFIStats, cachedHFIStats.data) + ) { + // Update state from cached data if required + dispatch( + getDataSuccess({ + lastUpdated: DateTime.now().toISO(), + fireShapeAreas: cachedFireShapeAreas.data, + provincialSummaries: cachedProvincialSummaries.data, + tpiStats: cachedTPIStats.data, + hfiStats: cachedHFIStats.data, + }) + ); + } + return; + } + + // Cached data is not available or is stale so we need to fetch and cache if we're online. + const { networkStatus } = getState().networkStatus; + if (networkStatus.connected) { + try { + dispatch(getDataStart()); + const provincialSummaries = await fetchProvincialSummaries( + todayKey, + tomorrowKey, + runParameters + ); + const fireShapeAreas = await fetchFireShapeAreas( + todayKey, + tomorrowKey, + runParameters + ); + const tpiStats = await fetchTpiStats( + todayKey, + tomorrowKey, + runParameters + ); + const hfiStats = await fetchHFIStats( + todayKey, + tomorrowKey, + runParameters + ); + + // Should we validate the new data in some way or assume a happy path? + // Write all new data to cache + await writeToFileSystem( + Filesystem, + PROVINCIAL_SUMMARY_KEY, + provincialSummaries, + today + ); + await writeToFileSystem( + Filesystem, + FIRE_SHAPE_AREAS_KEY, + fireShapeAreas, + today + ); + await writeToFileSystem(Filesystem, TPI_STATS_KEY, tpiStats, today); + await writeToFileSystem(Filesystem, HFI_STATS_KEY, hfiStats, today); + + // Update state + dispatch( + getDataSuccess({ + lastUpdated: DateTime.now().toISO(), + fireShapeAreas: fireShapeAreas, + provincialSummaries, + tpiStats, + hfiStats, + }) + ); + } catch (err) { + dispatch(getDataFailed((err as Error).toString())); + console.error(err); + } + } else { + dispatch(getDataFailed("Unable to update data. Data may be stale.")); + } +}; diff --git a/mobile/asa-go/src/slices/fireCentersSlice.test.ts b/mobile/asa-go/src/slices/fireCentersSlice.test.ts new file mode 100644 index 0000000000..930ab34931 --- /dev/null +++ b/mobile/asa-go/src/slices/fireCentersSlice.test.ts @@ -0,0 +1,179 @@ +vi.mock("@/utils/storage", () => ({ + writeToFileSystem: vi.fn(), + readFromFilesystem: vi.fn(), + FIRE_CENTERS_KEY: "fireCenters", + FIRE_CENTERS_CACHE_EXPIRATION: 12, +})); + +vi.mock("api/fbaAPI", () => ({ + getFBAFireCenters: vi.fn(), +})); + +import { createTestStore } from "@/testUtils"; +import { FIRE_CENTERS_KEY, readFromFilesystem } from "@/utils/storage"; +import { FireCenter, getFBAFireCenters } from "api/fbaAPI"; +import { DateTime } from "luxon"; +import { describe, expect, it, Mock, vi } from "vitest"; +import reducer, { + fetchFireCenters, + getFireCentersFailed, + getFireCentersStart, + getFireCentersSuccess, + initialState, +} from "./fireCentersSlice"; + +describe("fireCentersSlice reducers", () => { + it("should handle getFireCentersStart", () => { + const nextState = reducer(initialState, getFireCentersStart()); + expect(nextState.loading).toBe(true); + expect(nextState.error).toBeNull(); + expect(nextState.fireCenters).toEqual([]); + }); + + it("should handle getFireCentersFailed", () => { + const errorMsg = "Network error"; + const nextState = reducer(initialState, getFireCentersFailed(errorMsg)); + expect(nextState.loading).toBe(false); + expect(nextState.error).toBe(errorMsg); + }); + + it("should handle getFireCentersSuccess", () => { + const mockData: FireCenter[] = [ + { id: 1, name: "Center A", stations: [] } as FireCenter, + ]; + const nextState = reducer(initialState, getFireCentersSuccess(mockData)); + expect(nextState.loading).toBe(false); + expect(nextState.error).toBeNull(); + expect(nextState.fireCenters).toEqual(mockData); + }); +}); + +describe("fetchFireCenters thunk", () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + const today = DateTime.now().toISO(); + const yesterday = DateTime.now().plus({ days: -1 }).toISO(); + const mockFireCenterA: FireCenter = { + id: 1, + name: "test", + stations: [], + }; + const mockFireCenterB: FireCenter = { + id: 2, + name: "foo", + stations: [], + }; + const mockCacheWithNoData = () => { + (readFromFilesystem as Mock).mockImplementation((filesystem, key) => { + console.log("Reading from null file system"); + return null; + }); + }; + const mockCacheWithData = (isStale: boolean) => { + (readFromFilesystem as Mock).mockImplementation((filesystem, key) => { + if (key === FIRE_CENTERS_KEY) { + return { + lastUpdated: isStale ? yesterday : today, + data: isStale ? [mockFireCenterA] : [mockFireCenterB], + }; + } else { + return null; + } + }); + }; + + it("should call API and dispatch success when cache is empty", async () => { + mockCacheWithNoData(); + (getFBAFireCenters as Mock).mockResolvedValue({ + fire_centers: [mockFireCenterA], + }); + const store = createTestStore({ + fireCenters: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + await store.dispatch(fetchFireCenters()); + const state = store.getState().fireCenters; + expect(state.fireCenters).toEqual([mockFireCenterA]); + expect(state.loading).toBe(false); + expect(getFBAFireCenters).toHaveBeenCalledOnce(); + }); + + it("should call API and dispatch success when cache is stale", async () => { + mockCacheWithData(true); + (getFBAFireCenters as Mock).mockResolvedValue({ + fire_centers: [mockFireCenterB], + }); + const store = createTestStore({ + fireCenters: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + await store.dispatch(fetchFireCenters()); + const state = store.getState().fireCenters; + expect(state.fireCenters).toEqual([mockFireCenterB]); + expect(state.loading).toBe(false); + expect(getFBAFireCenters).toHaveBeenCalledOnce(); + }); + + it("should not call API when cache is fresh", async () => { + mockCacheWithData(false); + const store = createTestStore({ + fireCenters: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + await store.dispatch(fetchFireCenters()); + const state = store.getState().fireCenters; + expect(state.fireCenters).toEqual([mockFireCenterB]); + expect(state.loading).toBe(false); + expect(getFBAFireCenters).not.toBeCalled(); + }); + + it("should dispatch error when cache is empty and app is offline", async () => { + mockCacheWithNoData(); + const store = createTestStore({ + fireCenters: { ...initialState }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }); + await store.dispatch(fetchFireCenters()); + const state = store.getState().fireCenters; + expect(state.loading).toBe(false); + expect(state.error).toMatch(/Unable to refresh fire center data/); + }); + + it("should dispatch success when cache is stale and app is offline", async () => { + mockCacheWithData(true); + const store = createTestStore({ + fireCenters: { ...initialState }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }); + await store.dispatch(fetchFireCenters()); + const state = store.getState().fireCenters; + expect(state.loading).toBe(false); + expect(state.fireCenters).toEqual([mockFireCenterA]) + }); + + it("should dispatch error when cache is empty and app is offline", async () => { + mockCacheWithNoData(); + const store = createTestStore({ + fireCenters: { ...initialState }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }); + await store.dispatch(fetchFireCenters()); + const state = store.getState().fireCenters; + expect(state.loading).toBe(false); + expect(state.error).toMatch(/Unable to refresh fire center data/); + }); +}); diff --git a/mobile/asa-go/src/slices/fireCentersSlice.ts b/mobile/asa-go/src/slices/fireCentersSlice.ts index e6a0c9ef0a..b4f1993bff 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.ts @@ -1,7 +1,16 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - +import { today } from "@/utils/dataSliceUtils"; import { AppThunk } from "@/store"; -import { FBAResponse, FireCenter, getFBAFireCenters } from "api/fbaAPI"; +import { + FIRE_CENTERS_CACHE_EXPIRATION, + FIRE_CENTERS_KEY, + readFromFilesystem, + writeToFileSystem, +} from "@/utils/storage"; +import { Filesystem } from "@capacitor/filesystem"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { FireCenter, getFBAFireCenters } from "api/fbaAPI"; +import { isNull } from "lodash"; +import { DateTime } from "luxon"; export interface FireCentresState { loading: boolean; @@ -9,7 +18,7 @@ export interface FireCentresState { fireCenters: FireCenter[]; } -const initialState: FireCentresState = { +export const initialState: FireCentresState = { loading: false, error: null, fireCenters: [], @@ -33,10 +42,10 @@ const fireCentersSlice = createSlice({ }, getFireCentersSuccess( state: FireCentresState, - action: PayloadAction + action: PayloadAction ) { state.error = null; - state.fireCenters = action.payload.fire_centers; + state.fireCenters = action.payload; state.loading = false; }, }, @@ -50,13 +59,43 @@ export const { export default fireCentersSlice.reducer; -export const fetchFireCenters = (): AppThunk => async (dispatch) => { - try { - dispatch(getFireCentersStart()); - const fireCenters = await getFBAFireCenters(); - dispatch(getFireCentersSuccess(fireCenters)); - } catch (err) { - dispatch(getFireCentersFailed((err as Error).toString())); - console.log(err); +export const fetchFireCenters = (): AppThunk => async (dispatch, getState) => { + // Check for cached fire centers data. If the data is not stale save it in redux state. + const cachedFireCenters = await readFromFilesystem( + Filesystem, + FIRE_CENTERS_KEY + ); + const networkStatus = getState().networkStatus; + if (!isNull(cachedFireCenters)) { + const lastUpdated = DateTime.fromISO(cachedFireCenters.lastUpdated); + // Update state from the cached data if it isn't stale or if we're offline. + if (lastUpdated.plus({ hours: FIRE_CENTERS_CACHE_EXPIRATION }) > today || !networkStatus.networkStatus.connected) { + dispatch(getFireCentersSuccess(cachedFireCenters.data as FireCenter[])); + return; + } + } + // Cached data is not available or is stale so we need to fetch and cache if we're online. + if (networkStatus.networkStatus.connected) { + try { + dispatch(getFireCentersStart()); + const fireCenters = await getFBAFireCenters(); + await writeToFileSystem( + Filesystem, + FIRE_CENTERS_KEY, + fireCenters.fire_centers, + today + ); + dispatch(getFireCentersSuccess(fireCenters.fire_centers)); + } catch (err) { + dispatch(getFireCentersFailed((err as Error).toString())); + console.log(err); + } + } else { + // We're offline so there is nothing to do but set the error state. + dispatch( + getFireCentersFailed( + "Unable to refresh fire center data. Data may be stale." + ) + ); } }; diff --git a/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts b/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts deleted file mode 100644 index e0111d483a..0000000000 --- a/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; - -import { AppThunk, RootState } from "@/store"; -import { filterHFIFuelStatsByArea } from "@/utils/hfiStatsUtils"; -import { FireCentreHFIStats, getFireCentreHFIStats, RunType } from "api/fbaAPI"; - -export interface FireCentreHFIFuelStatsState { - error: string | null; - fireCentreHFIFuelStats: FireCentreHFIStats; -} - -export const initialState: FireCentreHFIFuelStatsState = { - error: null, - fireCentreHFIFuelStats: {}, -}; - -const fireCentreHFIFuelStatsSlice = createSlice({ - name: "fireCentreHfiFuelStats", - initialState, - reducers: { - getFireCentreHFIFuelStatsStart(state: FireCentreHFIFuelStatsState) { - state.error = null; - state.fireCentreHFIFuelStats = {}; - }, - getFireCentreHFIFuelStatsFailed( - state: FireCentreHFIFuelStatsState, - action: PayloadAction - ) { - state.error = action.payload; - }, - getFireCentreHFIFuelStatsSuccess( - state: FireCentreHFIFuelStatsState, - action: PayloadAction - ) { - state.error = null; - state.fireCentreHFIFuelStats = action.payload; - }, - }, -}); - -export const { - getFireCentreHFIFuelStatsStart, - getFireCentreHFIFuelStatsFailed, - getFireCentreHFIFuelStatsSuccess, -} = fireCentreHFIFuelStatsSlice.actions; - -export default fireCentreHFIFuelStatsSlice.reducer; - -export const fetchFireCentreHFIFuelStats = - ( - fireCentre: string, - runType: RunType, - forDate: string, - runDatetime: string - ): AppThunk => - async (dispatch) => { - try { - dispatch(getFireCentreHFIFuelStatsStart()); - const data = await getFireCentreHFIStats( - runType, - forDate, - runDatetime, - fireCentre - ); - dispatch(getFireCentreHFIFuelStatsSuccess(data)); - } catch (err) { - dispatch(getFireCentreHFIFuelStatsFailed((err as Error).toString())); - console.log(err); - } - }; - -export const selectFireCentreHFIFuelStats = (state: RootState) => - state.fireCentreHFIFuelStats; - -export const selectFilteredFireCentreHFIFuelStats = createSelector( - [selectFireCentreHFIFuelStats], - (fireCentreHFIFuelStats) => - filterHFIFuelStatsByArea(fireCentreHFIFuelStats.fireCentreHFIFuelStats) -); diff --git a/mobile/asa-go/src/slices/fireCentreTPIStatsSlice.ts b/mobile/asa-go/src/slices/fireCentreTPIStatsSlice.ts deleted file mode 100644 index 1f6fb7b7cf..0000000000 --- a/mobile/asa-go/src/slices/fireCentreTPIStatsSlice.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -import { AppThunk } from "@/store"; - -import { - FireCentreTPIResponse, - getFireCentreTPIStats, - RunType, -} from "api/fbaAPI"; - -export interface CentreTPIStatsState { - error: string | null; - fireCentreTPIStats: FireCentreTPIResponse | null; -} - -export const initialState: CentreTPIStatsState = { - error: null, - fireCentreTPIStats: null, -}; - -const fireCentreTPIStatsSlice = createSlice({ - name: "fireCentreTPIStats", - initialState, - reducers: { - getFireCentreTPIStatsStart(state: CentreTPIStatsState) { - state.error = null; - state.fireCentreTPIStats = null; - }, - getFireCentreTPIStatsFailed( - state: CentreTPIStatsState, - action: PayloadAction - ) { - state.error = action.payload; - state.fireCentreTPIStats = null; - }, - getFireCentreTPIStatsSuccess( - state: CentreTPIStatsState, - action: PayloadAction - ) { - state.error = null; - state.fireCentreTPIStats = action.payload; - }, - }, -}); - -export const { - getFireCentreTPIStatsStart, - getFireCentreTPIStatsFailed, - getFireCentreTPIStatsSuccess, -} = fireCentreTPIStatsSlice.actions; - -export default fireCentreTPIStatsSlice.reducer; - -export const fetchFireCentreTPIStats = - ( - fireCentre: string, - runType: RunType, - forDate: string, - runDatetime: string - ): AppThunk => - async (dispatch) => { - try { - dispatch(getFireCentreTPIStatsStart()); - const fireCentreTPIStats = await getFireCentreTPIStats( - fireCentre, - runType, - forDate, - runDatetime - ); - dispatch(getFireCentreTPIStatsSuccess(fireCentreTPIStats)); - } catch (err) { - dispatch(getFireCentreTPIStatsFailed((err as Error).toString())); - console.log(err); - } - }; diff --git a/mobile/asa-go/src/slices/fireZoneAreasSlice.ts b/mobile/asa-go/src/slices/fireZoneAreasSlice.ts deleted file mode 100644 index ae675b66e6..0000000000 --- a/mobile/asa-go/src/slices/fireZoneAreasSlice.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -import { AppThunk } from "@/store"; -import { - FireShapeArea, - FireShapeAreaListResponse, - getFireShapeAreas, - RunType, -} from "api/fbaAPI"; -import { isNull, isUndefined } from "lodash"; - -export interface FireZoneAreasState { - loading: boolean; - error: string | null; - fireShapeAreas: FireShapeArea[]; -} - -const initialState: FireZoneAreasState = { - loading: false, - error: null, - fireShapeAreas: [], -}; - -const fireShapeAreasSlice = createSlice({ - name: "fireShapeAreas", - initialState, - reducers: { - getFireShapeAreasStart(state: FireZoneAreasState) { - state.error = null; - state.loading = true; - state.fireShapeAreas = []; - }, - getFireShapeAreasFailed( - state: FireZoneAreasState, - action: PayloadAction - ) { - state.error = action.payload; - state.loading = false; - }, - getFireShapeAreasSuccess( - state: FireZoneAreasState, - action: PayloadAction - ) { - state.error = null; - state.fireShapeAreas = action.payload.shapes; - state.loading = false; - }, - }, -}); - -export const { - getFireShapeAreasStart, - getFireShapeAreasFailed, - getFireShapeAreasSuccess, -} = fireShapeAreasSlice.actions; - -export default fireShapeAreasSlice.reducer; - -export const fetchFireShapeAreas = - (runType: RunType, run_datetime: string | null, for_date: string): AppThunk => - async (dispatch) => { - if (!isUndefined(run_datetime) && !isNull(run_datetime)) { - try { - dispatch(getFireShapeAreasStart()); - const fireShapeAreas = await getFireShapeAreas( - runType, - run_datetime, - for_date - ); - dispatch(getFireShapeAreasSuccess(fireShapeAreas)); - } catch (err) { - dispatch(getFireShapeAreasFailed((err as Error).toString())); - console.log(err); - } - } else { - try { - dispatch(getFireShapeAreasSuccess({ shapes: [] })); - } catch (err) { - dispatch(getFireShapeAreasFailed((err as Error).toString())); - console.log(err); - } - } - }; diff --git a/mobile/asa-go/src/slices/fireZoneElevationInfoSlice.ts b/mobile/asa-go/src/slices/fireZoneElevationInfoSlice.ts deleted file mode 100644 index 2049734ad1..0000000000 --- a/mobile/asa-go/src/slices/fireZoneElevationInfoSlice.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -import { AppThunk } from "@/store"; - -import { - ElevationInfoByThreshold, - FireZoneElevationInfoResponse, - getFireZoneElevationInfo, - RunType, -} from "api/fbaAPI"; - -export interface ZoneElevationInfoState { - loading: boolean; - error: string | null; - fireZoneElevationInfo: ElevationInfoByThreshold[]; -} - -const initialState: ZoneElevationInfoState = { - loading: false, - error: null, - fireZoneElevationInfo: [], -}; - -const fireZoneElevationInfoSlice = createSlice({ - name: "fireZoneElevationInfo", - initialState, - reducers: { - getFireZoneElevationInfoStart(state: ZoneElevationInfoState) { - state.error = null; - state.fireZoneElevationInfo = []; - state.loading = true; - }, - getFireZoneElevationInfoFailed( - state: ZoneElevationInfoState, - action: PayloadAction - ) { - state.error = action.payload; - state.loading = false; - }, - getFireZoneElevationInfoStartSuccess( - state: ZoneElevationInfoState, - action: PayloadAction - ) { - state.error = null; - state.fireZoneElevationInfo = action.payload.hfi_elevation_info; - state.loading = false; - }, - }, -}); - -export const { - getFireZoneElevationInfoStart, - getFireZoneElevationInfoFailed, - getFireZoneElevationInfoStartSuccess, -} = fireZoneElevationInfoSlice.actions; - -export default fireZoneElevationInfoSlice.reducer; - -export const fetchfireZoneElevationInfo = - ( - fire_zone_id: number, - runType: RunType, - forDate: string, - runDatetime: string - ): AppThunk => - async (dispatch) => { - try { - dispatch(getFireZoneElevationInfoStart()); - const fireZoneElevationInfo = await getFireZoneElevationInfo( - fire_zone_id, - runType, - forDate, - runDatetime - ); - dispatch(getFireZoneElevationInfoStartSuccess(fireZoneElevationInfo)); - } catch (err) { - dispatch(getFireZoneElevationInfoFailed((err as Error).toString())); - console.log(err); - } - }; diff --git a/mobile/asa-go/src/slices/provincialSummarySlice.ts b/mobile/asa-go/src/slices/provincialSummarySlice.ts deleted file mode 100644 index 7cf993b5d4..0000000000 --- a/mobile/asa-go/src/slices/provincialSummarySlice.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { AppThunk, RootState } from "@/store"; -import { groupBy, isNull, isUndefined } from "lodash"; -import { - FireShapeAreaDetail, - getProvincialSummary, - ProvincialSummaryResponse, - RunType, -} from "api/fbaAPI"; - -export interface ProvincialSummaryState { - loading: boolean; - error: string | null; - fireShapeAreaDetails: FireShapeAreaDetail[]; -} - -export const initialState: ProvincialSummaryState = { - loading: false, - error: null, - fireShapeAreaDetails: [], -}; - -const provincialSummarySlice = createSlice({ - name: "provincialSummary", - initialState, - reducers: { - getProvincialSummaryStart(state: ProvincialSummaryState) { - state.error = null; - state.loading = true; - state.fireShapeAreaDetails = []; - }, - getProvincialSummaryFailed( - state: ProvincialSummaryState, - action: PayloadAction - ) { - state.error = action.payload; - state.loading = false; - }, - getProvincialSummarySuccess( - state: ProvincialSummaryState, - action: PayloadAction - ) { - state.error = null; - state.fireShapeAreaDetails = action.payload.provincial_summary; - state.loading = false; - }, - }, -}); - -export const { - getProvincialSummaryStart, - getProvincialSummaryFailed, - getProvincialSummarySuccess, -} = provincialSummarySlice.actions; - -export default provincialSummarySlice.reducer; - -export const fetchProvincialSummary = - (runType: RunType, run_datetime: string | null, for_date: string): AppThunk => - async (dispatch) => { - if (!isUndefined(run_datetime) && !isNull(run_datetime)) { - try { - dispatch(getProvincialSummaryStart()); - const fireShapeAreas = await getProvincialSummary( - runType, - run_datetime, - for_date - ); - dispatch(getProvincialSummarySuccess(fireShapeAreas)); - } catch (err) { - dispatch(getProvincialSummaryFailed((err as Error).toString())); - console.log(err); - } - } else { - try { - dispatch(getProvincialSummarySuccess({ provincial_summary: [] })); - } catch (err) { - dispatch(getProvincialSummaryFailed((err as Error).toString())); - console.log(err); - } - } - }; - -const selectFireShapeAreaDetails = (state: RootState) => - state.provincialSummary; - -export const selectProvincialSummary = createSelector( - [selectFireShapeAreaDetails], - (fireShapeAreaDetails) => { - const groupedByFireCenter = groupBy( - fireShapeAreaDetails.fireShapeAreaDetails, - "fire_centre_name" - ); - return groupedByFireCenter; - } -); diff --git a/mobile/asa-go/src/slices/runParameterSlice.ts b/mobile/asa-go/src/slices/runParameterSlice.ts deleted file mode 100644 index 0f393fdee7..0000000000 --- a/mobile/asa-go/src/slices/runParameterSlice.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { AppThunk, RootState } from "@/store"; -import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { getMostRecentRunParameter, RunType } from "api/fbaAPI"; -import { isNull } from "lodash"; -import { DateTime } from "luxon"; - -export interface RunParameterState { - loading: boolean; - error: string | null; - forDate: string | null; - runDatetime: string | null; - runType: RunType | null; -} - -export const initialState: RunParameterState = { - loading: false, - error: null, - forDate: null, - runDatetime: null, - runType: null, -}; - -const runParameterSlice = createSlice({ - name: "runParameter", - initialState, - reducers: { - getRunParameterStart(state: RunParameterState) { - state.error = null; - state.loading = true; - state.forDate = null; - state.runDatetime = null; - state.runType = null; - }, - getRunParameterFailed( - state: RunParameterState, - action: PayloadAction - ) { - state.error = action.payload; - state.loading = false; - state.forDate = null; - state.runDatetime = null; - state.runType = null; - }, - getRunParameterSuccess( - state: RunParameterState, - action: PayloadAction<{ - forDate: string; - runDateTime: string; - runType: RunType; - }> - ) { - state.error = null; - state.forDate = action.payload.forDate; - state.runDatetime = action.payload.runDateTime; - state.runType = action.payload.runType; - state.loading = false; - }, - }, -}); - -export const { - getRunParameterStart, - getRunParameterFailed, - getRunParameterSuccess, -} = runParameterSlice.actions; - -export default runParameterSlice.reducer; - -export const fetchMostRecentSFMSRunParameter = - (forDate: string): AppThunk => - async (dispatch) => { - try { - dispatch(getRunParameterStart()); - const runParameter = await getMostRecentRunParameter(forDate); - dispatch( - getRunParameterSuccess({ - forDate: runParameter?.for_date ?? null, - runDateTime: runParameter?.run_datetime ?? null, - runType: runParameter?.run_type ?? null, - }) - ); - } catch (err) { - dispatch(getRunParameterFailed((err as Error).toString())); - console.log(err); - } - }; - -const selectForDateString = (state: RootState) => state.runParameter.forDate; -const selectRunDatetimeString = (state: RootState) => - state.runParameter.runDatetime; - -export const selectForDate = createSelector( - [selectForDateString], - (forDateString) => - isNull(forDateString) ? null : DateTime.fromISO(forDateString) -); - -export const selectRunDatetime = createSelector( - [selectRunDatetimeString], - (selectRunDatetimeString) => - isNull(selectRunDatetimeString) - ? null - : DateTime.fromISO(selectRunDatetimeString) -); diff --git a/mobile/asa-go/src/slices/runParametersSlice.test.ts b/mobile/asa-go/src/slices/runParametersSlice.test.ts new file mode 100644 index 0000000000..3efe707400 --- /dev/null +++ b/mobile/asa-go/src/slices/runParametersSlice.test.ts @@ -0,0 +1,161 @@ +import reducer, { + fetchSFMSRunParameters, + getRunParametersFailed, + getRunParametersStart, + getRunParametersSuccess, + initialState, + selectRunParameters, +} from "@/slices/runParametersSlice"; +import { createTestStore } from "@/testUtils"; +import { describe, expect, it, Mock, vi } from "vitest"; + +// Mocks +vi.mock(import("api/fbaAPI"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getMostRecentRunParameters: vi.fn(), + }; +}); + +vi.mock("@/utils/storage", () => ({ + writeToFileSystem: vi.fn(), + readFromFilesystem: vi.fn(), + RUN_PARAMETERS_CACHE_KEY: "runParameters", +})); + +import { getTodayKey, getTomorrowKey } from "@/utils/dataSliceUtils"; +import { RootState } from "@/store"; +import { readFromFilesystem, writeToFileSystem } from "@/utils/storage"; +import { getMostRecentRunParameters, RunParameter, RunType } from "api/fbaAPI"; + +const todayKey = getTodayKey(); +const tomorrowKey = getTomorrowKey(); + +const mockRunParameters: { [key: string]: RunParameter } = { + [todayKey]: { + for_date: todayKey, + run_datetime: "2025-08-27T08:00:00Z", + run_type: RunType.FORECAST, + }, + [tomorrowKey]: { + for_date: tomorrowKey, + run_datetime: "2025-08-28T08:00:00Z", + run_type: RunType.FORECAST, + }, +}; + +describe("runParameters reducer", () => { + it("should handle getRunParametersStart", () => { + const nextState = reducer(initialState, getRunParametersStart()); + expect(nextState.loading).toBe(true); + expect(nextState.error).toBeNull(); + }); + + it("should handle getRunParametersFailed", () => { + const error = "Failed to fetch"; + const nextState = reducer(initialState, getRunParametersFailed(error)); + expect(nextState.loading).toBe(false); + expect(nextState.error).toBe(error); + }); + + it("should handle getRunParametersSuccess", () => { + const nextState = reducer( + initialState, + getRunParametersSuccess({ runParameters: mockRunParameters }) + ); + expect(nextState.loading).toBe(false); + expect(nextState.error).toBeNull(); + expect(nextState.runParameters).toEqual(mockRunParameters); + }); +}); + +describe("fetchSFMSRunParameters thunk", () => { + it("dispatches success when online and API returns data", async () => { + (getMostRecentRunParameters as Mock).mockResolvedValue(mockRunParameters); + (writeToFileSystem as Mock).mockResolvedValue(undefined); + const store = createTestStore({ + runParameters: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + await store.dispatch(fetchSFMSRunParameters()); + expect(store.getState().runParameters.runParameters).toBe( + mockRunParameters + ); + expect(writeToFileSystem).toBeCalled(); + }); + + it("does not dispatch success when online and API returns data if current state matches API response", async () => { + (getMostRecentRunParameters as Mock).mockResolvedValue(mockRunParameters); + (writeToFileSystem as Mock).mockResolvedValue(undefined); + const store = createTestStore({ + runParameters: { ...initialState, runParameters: mockRunParameters }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + await store.dispatch(fetchSFMSRunParameters()); + expect(store.getState().runParameters.runParameters).toBe( + mockRunParameters + ); + expect(writeToFileSystem).toBeCalled(); + }); + + it("dispatches failure when API throws", async () => { + const errorMessage = "API error"; + (getMostRecentRunParameters as Mock).mockRejectedValue( + new Error(errorMessage) + ); + const store = createTestStore({ + runParameters: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + await store.dispatch(fetchSFMSRunParameters()); + expect(store.getState().runParameters.error).toContain(errorMessage); + }); + + it("dispatches success from cache when offline", async () => { + (readFromFilesystem as Mock).mockResolvedValue({ data: mockRunParameters }); + const store = createTestStore({ + runParameters: { ...initialState }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }); + await store.dispatch(fetchSFMSRunParameters()); + expect(store.getState().runParameters.runParameters).toBe( + mockRunParameters + ); + }); + + it("dispatches failure when offline and no cache", async () => { + (readFromFilesystem as Mock).mockResolvedValue(null); + const store = createTestStore({ + runParameters: { ...initialState }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }); + await store.dispatch(fetchSFMSRunParameters()); + expect(store.getState().runParameters.error).toBe( + "No run parameters available." + ); + }); +}); + +describe("selectRunParameters", () => { + it("should return runParameters from state", () => { + const state = { + runParameters: { + ...initialState, + runParameters: mockRunParameters, + }, + }; + const result = selectRunParameters(state as RootState); + expect(result).toEqual(mockRunParameters); + }); +}); diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts new file mode 100644 index 0000000000..eceff4c5f4 --- /dev/null +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -0,0 +1,182 @@ +import { getTodayKey, getTomorrowKey, today } from "@/utils/dataSliceUtils"; +import { AppThunk, RootState } from "@/store"; +import { + readFromFilesystem, + RUN_PARAMETERS_CACHE_KEY, + writeToFileSystem, +} from "@/utils/storage"; +import { Filesystem } from "@capacitor/filesystem"; +import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { getMostRecentRunParameters, RunParameter } from "api/fbaAPI"; +import { isNil } from "lodash"; + +export interface RunParametersState { + loading: boolean; + error: string | null; + runParameters: { [key: string]: RunParameter } | null; +} + +export const initialState: RunParametersState = { + loading: false, + error: null, + runParameters: null, +}; + +const runParameterSlice = createSlice({ + name: "runParameters", + initialState, + reducers: { + getRunParametersStart(state: RunParametersState) { + state.error = null; + state.loading = true; + }, + getRunParametersFailed( + state: RunParametersState, + action: PayloadAction + ) { + state.error = action.payload; + state.loading = false; + }, + getRunParametersSuccess( + state: RunParametersState, + action: PayloadAction<{ + runParameters: { [key: string]: RunParameter }; + }> + ) { + state.error = null; + state.runParameters = action.payload.runParameters; + state.loading = false; + }, + }, +}); + +export const { + getRunParametersStart, + getRunParametersFailed, + getRunParametersSuccess, +} = runParameterSlice.actions; + +export default runParameterSlice.reducer; + +export const fetchSFMSRunParameters = + (): AppThunk => async (dispatch, getState) => { + const todayKey = getTodayKey(); + const tomorrowKey = getTomorrowKey(); + const state = getState(); + const connected = state.networkStatus.networkStatus.connected; + const reduxRunParameters = state.runParameters.runParameters; + if (connected) { + // We're online so fetch SFMS run times from the API for today and tomorrow. + try { + dispatch(getRunParametersStart()); + const latestRunParameters: { [key: string]: RunParameter } = + await getMostRecentRunParameters(todayKey, tomorrowKey); + if ( + !isNil(latestRunParameters) && + !isNil(latestRunParameters[todayKey]) && + !isNil(latestRunParameters[tomorrowKey]) + ) { + // Cache the run parameters for today and tomorrow + await writeToFileSystem( + Filesystem, + RUN_PARAMETERS_CACHE_KEY, + latestRunParameters, + today + ); + + if ( + isNil(reduxRunParameters) || + stateUpdateRequired( + todayKey, + tomorrowKey, + reduxRunParameters, + latestRunParameters + ) + ) { + // Retrieved run parameters differ from redux state so update + dispatch( + getRunParametersSuccess({ + runParameters: latestRunParameters, + }) + ); + return; + } + } + dispatch( + getRunParametersFailed("Unable to update runParameters from the API.") + ); + return; + } catch (err) { + dispatch(getRunParametersFailed((err as Error).toString())); + console.log(err); + return; + } + } else { + // We're offline, so check the cache for existing run parameters and update state with the + // values read from the cache if they differ from the values currently in state. + const cachedData = await readFromFilesystem( + Filesystem, + RUN_PARAMETERS_CACHE_KEY + ); + const cachedRunParameters: { [key: string]: RunParameter } | null = isNil( + cachedData + ) + ? null + : cachedData.data as { [key: string]: RunParameter }; + if ( + !isNil(cachedRunParameters) && + (isNil(reduxRunParameters) || + stateUpdateRequired( + todayKey, + tomorrowKey, + reduxRunParameters, + cachedRunParameters + )) + ) { + // Retrieved run parameters for the specified date differ from redux state so update + dispatch( + getRunParametersSuccess({ + runParameters: cachedRunParameters, + }) + ); + return; + } + // We're offline and there are no cached run parameters for today + dispatch(getRunParametersFailed("No run parameters available.")); + } + }; + +export const selectRunParameters = (state: RootState) => + state.runParameters.runParameters; + +export const selectRunParameterByForDate = (forDate: string) => { + createSelector([selectRunParameters], (runParameters) => { + return isNil(runParameters) ? null : runParameters[forDate]; + }); +}; + +const stateUpdateRequired = ( + todayKey: string, + tomorrowKey: string, + a: { [key: string]: RunParameter }, + b: { [key: string]: RunParameter } +) => { + if (isNil(a[todayKey]) || isNil(a[tomorrowKey])) { + return true; + } + if (isNil(b[todayKey]) || isNil(b[tomorrowKey])) { + return false; + } + return ( + !runParametersAreEqual(a[todayKey], b[todayKey]) || + !runParametersAreEqual(a[tomorrowKey], b[tomorrowKey]) + ); +}; + +const runParametersAreEqual = (a: RunParameter, b: RunParameter) => { + return ( + a.for_date === b.for_date && + a.run_datetime === b.run_datetime && + a.run_type === b.run_type + ); +}; diff --git a/mobile/asa-go/src/store.ts b/mobile/asa-go/src/store.ts index 87c7627cb7..7539198c66 100644 --- a/mobile/asa-go/src/store.ts +++ b/mobile/asa-go/src/store.ts @@ -12,12 +12,16 @@ export type AppDispatch = typeof store.dispatch; export type AppThunk = ThunkAction; -export const selectRunParameter = (state: RootState) => state.runParameter; -export const selectFireShapeAreas = (state: RootState) => state.fireShapeAreas; export const selectFireCenters = (state: RootState) => state.fireCenters; export const selectGeolocation = (state: RootState) => state.geolocation; export const selectAuthentication = (state: RootState) => state.authentication; export const selectNetworkStatus = (state: RootState) => state.networkStatus; -export const selectFireCentreTPIStats = (state: RootState) => - state.fireCentreTPIStats; export const selectToken = (state: RootState) => state.authentication.token; +export const selectRunParameters = (state: RootState) => + state.runParameters.runParameters; +export const selectProvincialSummaries = (state: RootState) => + state.data.provincialSummaries; +export const selectFireShapeAreas = (state: RootState) => + state.data.fireShapeAreas; +export const selectTPIStats = (state: RootState) => state.data.tpiStats; +export const selectHFIStats = (state: RootState) => state.data.hfiStats; diff --git a/mobile/asa-go/src/utils/calculateZoneStatus.ts b/mobile/asa-go/src/utils/calculateZoneStatus.ts index af7dcc8642..3f71c9259f 100644 --- a/mobile/asa-go/src/utils/calculateZoneStatus.ts +++ b/mobile/asa-go/src/utils/calculateZoneStatus.ts @@ -37,7 +37,7 @@ export const calculateStatusColour = ( }; export const calculateStatusText = ( - details: FireShapeAreaDetail[], + details: FireShapeAreaDetail[] | undefined, advisoryThreshold: number ): AdvisoryStatus | undefined => { if (isUndefined(details) || details.length === 0) { diff --git a/mobile/asa-go/src/utils/dataSliceUtils.test.ts b/mobile/asa-go/src/utils/dataSliceUtils.test.ts new file mode 100644 index 0000000000..cb48c49066 --- /dev/null +++ b/mobile/asa-go/src/utils/dataSliceUtils.test.ts @@ -0,0 +1,196 @@ +import { + FireShapeArea, + getFireShapeAreas, + getHFIStats, + getProvincialSummary, + getTPIStats, + RunParameter, + RunType, +} from "@/api/fbaAPI"; +import { + dataAreEqual, + fetchFireShapeArea, + fetchFireShapeAreas, + fetchHFIStatsForRunParameter, + fetchProvincialSummaries, + fetchProvincialSummary, + fetchTpiStatsForRunParameter, + runParametersMatch, + shapeDataForCaching, +} from "@/utils/dataSliceUtils"; +import { CacheableData } from "@/utils/storage"; +import { DateTime } from "luxon"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; + +vi.mock("@/api/fbaAPI", async () => { + const actual = await vi.importActual( + "@/api/fbaAPI" + ); + return { + ...actual, + getFireShapeAreas: vi.fn(), + getHFIStats: vi.fn(), + getTPIStats: vi.fn(), + getProvincialSummary: vi.fn(), + }; +}); + +const mockRunParameter: RunParameter = { + run_type: RunType.FORECAST, + run_datetime: "2025-11-20T00:00:00Z", + for_date: "2025-11-21", +}; + +const today = DateTime.now(); +const todayKey = today.toISODate(); +const tomorrowKey = today.plus({ days: 1 }).toISODate(); + +describe("Utility Functions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("runParametersMatch", () => { + it("returns true when runParameters match cached data", () => { + const runParameters = { + [todayKey]: mockRunParameter, + [tomorrowKey]: mockRunParameter, + }; + const data: CacheableData = { + [todayKey]: { runParameter: mockRunParameter, data: [] }, + [tomorrowKey]: { runParameter: mockRunParameter, data: [] }, + }; + + expect( + runParametersMatch(todayKey, tomorrowKey, runParameters, data) + ).toBe(true); + }); + + it("returns false when runParameters differ", () => { + const runParameters = { + [todayKey]: mockRunParameter, + [tomorrowKey]: { ...mockRunParameter, for_date: "2025-11-22" }, + }; + const data: CacheableData = { + [todayKey]: { runParameter: mockRunParameter, data: [] }, + [tomorrowKey]: { runParameter: mockRunParameter, data: [] }, + }; + + expect( + runParametersMatch(todayKey, tomorrowKey, runParameters, data) + ).toBe(false); + }); + }); + + describe("shapeDataForCaching", () => { + it("shapes data correctly", () => { + const result = shapeDataForCaching( + todayKey, + tomorrowKey, + { [todayKey]: mockRunParameter, [tomorrowKey]: mockRunParameter }, + [{ fire_shape_id: 1 } as FireShapeArea], + [{ fire_shape_id: 2 } as FireShapeArea] + ); + + expect((result[todayKey].data[0] as FireShapeArea).fire_shape_id).toBe(1); + expect((result[tomorrowKey].data[0] as FireShapeArea).fire_shape_id).toBe( + 2 + ); + }); + }); + + describe("dataAreEqual", () => { + it("returns true for equal data", () => { + const a: CacheableData = { + [todayKey]: { runParameter: mockRunParameter, data: [] }, + [tomorrowKey]: { runParameter: mockRunParameter, data: [] }, + }; + const b = { ...a }; + + expect(dataAreEqual(a, b)).toBe(true); + }); + + it("returns false for different data", () => { + const a: CacheableData = { + [todayKey]: { runParameter: mockRunParameter, data: [] }, + [tomorrowKey]: { runParameter: mockRunParameter, data: [] }, + }; + const b: CacheableData = { + [todayKey]: { + runParameter: mockRunParameter, + data: [{ fire_shape_id: 1 } as FireShapeArea], + }, + [tomorrowKey]: { runParameter: mockRunParameter, data: [] }, + }; + + expect(dataAreEqual(a, b)).toBe(false); + }); + }); +}); + +describe("Async Fetch Functions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetchFireShapeArea returns empty array if runParameter is nil", async () => { + const result = await fetchFireShapeArea(null as unknown as RunParameter); + expect(result).toEqual([]); + }); + + it("fetchFireShapeArea calls API and returns shapes", async () => { + (getFireShapeAreas as Mock).mockResolvedValue({ + shapes: [{ fire_shape_id: 1 }], + }); + const result = await fetchFireShapeArea(mockRunParameter); + expect(result).toEqual([{ fire_shape_id: 1 }]); + expect(getFireShapeAreas).toHaveBeenCalledWith( + mockRunParameter.run_type, + mockRunParameter.run_datetime, + mockRunParameter.for_date + ); + }); + + it("fetchFireShapeAreas returns shaped data", async () => { + (getFireShapeAreas as Mock).mockResolvedValue({ + shapes: [{ fire_shape_id: 1 }], + }); + const result = await fetchFireShapeAreas(todayKey, tomorrowKey, { + [todayKey]: mockRunParameter, + [tomorrowKey]: mockRunParameter, + }); + expect(result[todayKey].data[0].fire_shape_id).toBe(1); + }); + + it("fetchHFIStatsForRunParameter returns zone_data", async () => { + (getHFIStats as Mock).mockResolvedValue({ zone_data: { zone1: {} } }); + const result = await fetchHFIStatsForRunParameter(mockRunParameter); + expect(result).toEqual({ zone1: {} }); + }); + + it("fetchTpiStatsForRunParameter returns firezone_tpi_stats", async () => { + (getTPIStats as Mock).mockResolvedValue({ + firezone_tpi_stats: [{ fire_zone_id: 1 }], + }); + const result = await fetchTpiStatsForRunParameter(mockRunParameter); + expect(result).toEqual([{ fire_zone_id: 1 }]); + }); + + it("fetchProvincialSummary returns provincial_summary", async () => { + (getProvincialSummary as Mock).mockResolvedValue({ + provincial_summary: [{ fire_shape_id: 1 }], + }); + const result = await fetchProvincialSummary(mockRunParameter); + expect(result).toEqual([{ fire_shape_id: 1 }]); + }); + + it("fetchProvincialSummaries returns shaped data", async () => { + (getProvincialSummary as Mock).mockResolvedValue({ + provincial_summary: [{ fire_shape_id: 1 }], + }); + const result = await fetchProvincialSummaries(todayKey, "tomorrow", { + [todayKey]: mockRunParameter, + [tomorrowKey]: mockRunParameter, + }); + expect(result[todayKey].data[0].fire_shape_id).toBe(1); + }); +}); diff --git a/mobile/asa-go/src/utils/dataSliceUtils.ts b/mobile/asa-go/src/utils/dataSliceUtils.ts new file mode 100644 index 0000000000..7da916d1e4 --- /dev/null +++ b/mobile/asa-go/src/utils/dataSliceUtils.ts @@ -0,0 +1,206 @@ +import { + FireShapeArea, + FireShapeAreaDetail, + FireZoneHFIStatsDictionary, + FireZoneTPIStats, + getFireShapeAreas, + getHFIStats, + getProvincialSummary, + getTPIStats, + RunParameter, +} from "@/api/fbaAPI"; +import { PST_UTC_OFFSET } from "@/utils/constants"; +import { CacheableData, CacheableDataType } from "@/utils/storage"; +import { isEqual, isNil } from "lodash"; +import { DateTime } from "luxon"; + +export const today = DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`); +export const getTodayKey = () => { + return today.isValid ? today.toISODate() : ""; +}; +export const getTomorrowKey = () => { + return today.isValid ? today.plus({ days: 1 }).toISODate() : ""; +}; + +export const runParametersMatch = ( + todayKey: string, + tomorrowKey: string, + runParameters: { [key: string]: RunParameter }, + data: CacheableData +): boolean => { + return ( + isEqual(runParameters[todayKey], data[todayKey]?.runParameter) && + isEqual(runParameters[tomorrowKey], data[tomorrowKey]?.runParameter) + ); +}; + +export const fetchFireShapeArea = async ( + runParameter: RunParameter +): Promise => { + if (isNil(runParameter)) { + return []; + } + const fireShapeArea = await getFireShapeAreas( + runParameter.run_type, + runParameter.run_datetime, + runParameter.for_date + ); + return fireShapeArea?.shapes; +}; + +export const fetchFireShapeAreas = async ( + todayKey: string, + tomorrowKey: string, + runParameters: { [key: string]: RunParameter } +): Promise> => { + // API calls to get data for today and tomorrow + const todayFireShapeArea = await fetchFireShapeArea(runParameters[todayKey]); + const tomorrowFireShapeArea = await fetchFireShapeArea( + runParameters[tomorrowKey] + ); + const fireShapeAreas = shapeDataForCaching( + todayKey, + tomorrowKey, + runParameters, + todayFireShapeArea, + tomorrowFireShapeArea + ); + return fireShapeAreas as CacheableData; +}; + +export const fetchHFIStatsForRunParameter = async ( + runParameter: RunParameter +): Promise => { + if (isNil(runParameter)) { + return []; + } + const hfiStatsForRunParameter = await getHFIStats( + runParameter.run_type, + runParameter.run_datetime, + runParameter.for_date + ); + return hfiStatsForRunParameter?.zone_data; +}; + +export const fetchHFIStats = async ( + todayKey: string, + tomorrowKey: string, + runParameters: { [key: string]: RunParameter } +): Promise> => { + const hfiStatsForToday = await fetchHFIStatsForRunParameter( + runParameters[todayKey] + ); + const hfiStatsForTommorow = await fetchHFIStatsForRunParameter( + runParameters[tomorrowKey] + ); + const hfiStats = shapeDataForCaching( + todayKey, + tomorrowKey, + runParameters, + hfiStatsForToday, + hfiStatsForTommorow + ); + return hfiStats as CacheableData; +}; + +export const fetchTpiStatsForRunParameter = async ( + runParameter: RunParameter +): Promise => { + if (isNil(runParameter)) { + return []; + } + const tpiStatsForRunParameter = await getTPIStats( + runParameter.run_type, + runParameter.run_datetime, + runParameter.for_date + ); + return tpiStatsForRunParameter?.firezone_tpi_stats; +}; + +export const fetchTpiStats = async ( + todayKey: string, + tomorrowKey: string, + runParameters: { [key: string]: RunParameter } +): Promise> => { + const tpiStatsForToday = await fetchTpiStatsForRunParameter( + runParameters[todayKey] + ); + const tpiStatsForTommorow = await fetchTpiStatsForRunParameter( + runParameters[tomorrowKey] + ); + const tpiStats = shapeDataForCaching( + todayKey, + tomorrowKey, + runParameters, + tpiStatsForToday, + tpiStatsForTommorow + ); + return tpiStats as CacheableData; +}; + +export const fetchProvincialSummary = async ( + runParameter: RunParameter +): Promise => { + if (isNil(runParameter)) { + return []; + } + const provincialSummary = await getProvincialSummary( + runParameter.run_type, + runParameter.run_datetime, + runParameter.for_date + ); + return provincialSummary?.provincial_summary; +}; + +export const fetchProvincialSummaries = async ( + todayKey: string, + tomorrowKey: string, + runParameters: { [key: string]: RunParameter } +): Promise> => { + // API calls to get data for today and tomorrow + const todayProvincialSummary = await fetchProvincialSummary( + runParameters[todayKey] + ); + const tomorrowProvincialSummary = await fetchProvincialSummary( + runParameters[tomorrowKey] + ); + // Shape the data for caching and storing in state + const provincialSummaries = { + [todayKey]: { + runParameter: runParameters[todayKey], + data: todayProvincialSummary, + }, + [tomorrowKey]: { + runParameter: runParameters[tomorrowKey], + data: tomorrowProvincialSummary, + }, + }; + + return provincialSummaries; +}; + +export const shapeDataForCaching = ( + todayKey: string, + tomorrowKey: string, + runParameters: { [key: string]: RunParameter }, + todayData: CacheableDataType, + tomorrowData: CacheableDataType +): CacheableData => { + return { + [todayKey]: { + runParameter: runParameters[todayKey], + data: todayData, + }, + [tomorrowKey]: { + runParameter: runParameters[tomorrowKey], + data: tomorrowData, + }, + }; +}; + +export const dataAreEqual = ( + a: CacheableData | null, + b: CacheableData | null +): boolean => { + return isEqual(a, b); +}; diff --git a/mobile/asa-go/src/utils/hfiStatsUtils.ts b/mobile/asa-go/src/utils/hfiStatsUtils.ts index 5048ce9780..137bf7c264 100644 --- a/mobile/asa-go/src/utils/hfiStatsUtils.ts +++ b/mobile/asa-go/src/utils/hfiStatsUtils.ts @@ -1,7 +1,6 @@ import { - FireCentreHFIStats, FireZoneFuelStats, - FireZoneHFIStats, + FireZoneHFIStatsDictionary, } from "@/api/fbaAPI"; // Based on 100 pixels at a 2000m resolution fuel raster measured in square meters. @@ -14,20 +13,16 @@ const FUEL_TYPES_ALWAYS_INCLUDED = ["C-5", "S-1", "S-2", "S-3"]; * @returns FireCentreHFIStats with low prevalence fuel types filtered out. */ export const filterHFIFuelStatsByArea = ( - fireCentreHFIFuelStats: FireCentreHFIStats + fireCentreHFIFuelStats: FireZoneHFIStatsDictionary ) => { - const filteredFireCentreStats: FireCentreHFIStats = {}; - for (const [key, value] of Object.entries(fireCentreHFIFuelStats)) { - const fireZoneStats: { [fire_zone_id: number]: FireZoneHFIStats } = {}; - for (const [key2, value2] of Object.entries(value)) { - fireZoneStats[parseInt(key2)] = { - min_wind_stats: value2.min_wind_stats, - fuel_area_stats: filterHFIStatsByArea(value2.fuel_area_stats), + const filteredFireZoneStats: FireZoneHFIStatsDictionary = {}; + for (const [key, value] of Object.entries(fireCentreHFIFuelStats)) { + filteredFireZoneStats[Number.parseInt(key)] = { + min_wind_stats: value.min_wind_stats, + fuel_area_stats: filterHFIStatsByArea(value.fuel_area_stats), }; - } - filteredFireCentreStats[key] = fireZoneStats; } - return filteredFireCentreStats; + return filteredFireZoneStats; }; /** diff --git a/mobile/asa-go/src/utils/pmtilesCache.ts b/mobile/asa-go/src/utils/pmtilesCache.ts index 79ce11f203..329e0f8185 100644 --- a/mobile/asa-go/src/utils/pmtilesCache.ts +++ b/mobile/asa-go/src/utils/pmtilesCache.ts @@ -168,7 +168,7 @@ export class PMTilesCache implements IPMTilesCache { filename: string, fetchAndStoreCallback?: () => Promise ) => { - const cachedFilename = `${for_date.toISODate()}_${run_type}_${run_date.toISODate()}_${filename}`; + const cachedFilename = this.getHFIFileName(for_date.toISODate()!, run_type, run_date.toISODate()!, filename) const fetchAndStore = fetchAndStoreCallback ?? fetchAndStoreHFIPMTiles( @@ -180,4 +180,8 @@ export class PMTilesCache implements IPMTilesCache { ); return this.loadPMTiles(cachedFilename, fetchAndStore); }; + + public readonly getHFIFileName = (for_date: string, run_type: string, run_date: string, filename: string) => { + return `${for_date}_${run_type}_${run_date}_${filename}`; + } } diff --git a/mobile/asa-go/src/utils/storage.test.ts b/mobile/asa-go/src/utils/storage.test.ts new file mode 100644 index 0000000000..f1e19defa8 --- /dev/null +++ b/mobile/asa-go/src/utils/storage.test.ts @@ -0,0 +1,108 @@ + +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { DateTime } from 'luxon'; +import { + getPath, + writeToFileSystem, + readFromFilesystem, + clearStaleHFIPMTiles, + CacheableDataType, + CacheableData, +} from '@/utils/storage'; // adjust path as needed + +vi.mock("@capacitor/filesystem", () => ({ + Filesystem: { + readFile: vi.fn(), + writeFile: vi.fn(), + readdir: vi.fn(), + deleteFile: vi.fn() + }, + Directory: { Data: "DATA" }, + Encoding: { UTF8: "utf8" }, +})); +import { Directory, Encoding, Filesystem} from '@capacitor/filesystem'; +import { FireShapeArea, RunType } from '@/api/fbaAPI'; + +const mockData: FireShapeArea[] = [] +const mockCacheableData: CacheableData = { + "2025-08-25": { + runParameter: { + for_date: "2025-08-25", + run_datetime: "2025-08-24", + run_type: RunType.FORECAST + }, + data: mockData + } + } + const mockFileData = { "data": mockCacheableData } + const mockReadFileResult = { + data: JSON.stringify(mockFileData) + } + +describe('Storage utils', () => { + const key = 'testKey'; + const date = DateTime.fromISO('2025-08-26'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('getPath returns correct path with date', () => { + const path = getPath(key, date); + expect(path).toBe('_asa_go_testKey_2025-08-26.json'); + }); + + it('getPath returns correct path without date', () => { + const path = getPath(key); + expect(path).toBe('_asa_go_testKey.json'); + }); + + it('writeToFileSystem writes data correctly', async () => { + await writeToFileSystem(Filesystem, key, mockCacheableData, date); + + expect(Filesystem.writeFile).toHaveBeenCalledWith({ + path: '_asa_go_testKey.json', + data: expect.stringContaining('"lastUpdated":"2025-08-26T'), + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + }); + + it('readFromFilesystem returns parsed data', async () => { + (Filesystem.readFile as Mock).mockResolvedValue(mockReadFileResult); + + const result = await readFromFilesystem(Filesystem, key); + expect(result).toEqual(mockFileData); + expect(Filesystem.readFile).toHaveBeenCalledWith({ + path: '_asa_go_testKey.json', + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + }); + + it('readFromFilesystem returns null on error', async () => { + (Filesystem.readFile as Mock).mockRejectedValue(new Error('File not found')); + + const result = await readFromFilesystem(Filesystem, key); + expect(result).toBeNull(); + }); + + it('clearStaleHFIPMTiles deletes stale files', async () => { + (Filesystem.readdir as Mock).mockResolvedValue({ + files: [ + { name: '2025-08-25.hfi.pmtiles' }, + { name: '2025-08-26.hfi.pmtiles' }, + { name: 'otherfile.json' }, + ], + }); + + await clearStaleHFIPMTiles(Filesystem, ['2025-08-26.hfi.pmtiles']); + + expect(Filesystem.deleteFile).toHaveBeenCalledTimes(1); + expect(Filesystem.deleteFile).toHaveBeenCalledWith({ + path: '2025-08-25.hfi.pmtiles', + directory: Directory.Data, + }); + }); +}); + diff --git a/mobile/asa-go/src/utils/storage.ts b/mobile/asa-go/src/utils/storage.ts new file mode 100644 index 0000000000..aa808fdc15 --- /dev/null +++ b/mobile/asa-go/src/utils/storage.ts @@ -0,0 +1,104 @@ +import { + FireCenter, + FireShapeArea, + FireShapeAreaDetail, + FireZoneHFIStatsDictionary, + FireZoneTPIStats, + RunParameter, +} from "@/api/fbaAPI"; +import { Directory, Encoding, FilesystemPlugin } from "@capacitor/filesystem"; +import { DateTime } from "luxon"; + +export type CacheableDataType = + | FireShapeAreaDetail[] + | FireShapeArea[] + | FireZoneTPIStats[] + | FireZoneHFIStatsDictionary; + +export type CacheableData = { + [key: string]: { + runParameter: RunParameter; + data: T; + }; +}; + +type Cacheable = + | FireCenter[] + | { [key: string]: RunParameter }; + +// Type returned by readFromFilesystem function +export type CachedData | Cacheable> = { + lastUpdated: string, + data: T +} + +const CACHE_KEY = "_asa_go"; +export const FIRE_CENTERS_KEY = "fireCenters"; +export const FIRE_SHAPE_AREAS_KEY = "fireShapeAreas"; +export const HFI_STATS_KEY = "hfiStats"; +export const PROVINCIAL_SUMMARY_KEY = "provincialSummary"; +export const RUN_PARAMETERS_CACHE_KEY = "runParameters"; +export const TPI_STATS_KEY = "tpiStats"; +export const FIRE_CENTERS_CACHE_EXPIRATION = 12; + +export const getPath = (key: string, date?: DateTime): string => { + if (date) { + return `${CACHE_KEY}_${key}_${date.toISODate()}.json`; + } + return `${CACHE_KEY}_${key}.json`; +}; + +export const writeToFileSystem = async ( + filesystem: FilesystemPlugin, + key: string, + data: CacheableData | Cacheable, + lastUpdated: DateTime +) => { + await filesystem.writeFile({ + path: getPath(key), + data: JSON.stringify({ data, lastUpdated: lastUpdated.toISO() }), + directory: Directory.Data, + encoding: Encoding.UTF8, + }); +}; + +export const readFromFilesystem = async ( + filesystem: FilesystemPlugin, + key: string +): Promise | Cacheable> | null> => { + try { + const result = await filesystem.readFile({ + path: getPath(key), + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + return JSON.parse(result.data as string); + } catch { + return null; + } +}; + +export const clearStaleHFIPMTiles = async ( + filesystem: FilesystemPlugin, + hfiFilesToKeep: string[] +) => { + try { + const { files } = await filesystem.readdir({ + path: "", + directory: Directory.Data, + }); + for (const file of files) { + if ( + file.name.endsWith("hfi.pmtiles") && + !hfiFilesToKeep.includes(file.name) + ) { + await filesystem.deleteFile({ + path: file.name, + directory: Directory.Data, + }); + } + } + } catch (e) { + console.error(e); + } +}; diff --git a/wps_shared/wps_shared/db/crud/auto_spatial_advisory.py b/wps_shared/wps_shared/db/crud/auto_spatial_advisory.py index 9cfa8e4c26..4b61af1d51 100644 --- a/wps_shared/wps_shared/db/crud/auto_spatial_advisory.py +++ b/wps_shared/wps_shared/db/crud/auto_spatial_advisory.py @@ -5,13 +5,12 @@ from time import perf_counter from typing import List, Optional, Tuple -from sqlalchemy import String, and_, cast, extract, func, select, update +from sqlalchemy import String, and_, cast, desc, extract, func, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine.row import Row from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased -from wps_shared.run_type import RunType -from wps_shared.schemas.fba import HfiThreshold from wps_shared.db.models.auto_spatial_advisory import ( AdvisoryElevationStats, AdvisoryFuelStats, @@ -34,6 +33,8 @@ TPIFuelArea, ) from wps_shared.db.models.hfi_calc import FireCentre +from wps_shared.run_type import RunType +from wps_shared.schemas.fba import HfiThreshold logger = logging.getLogger(__name__) @@ -207,6 +208,19 @@ async def get_zone_source_ids_in_centre(session: AsyncSession, fire_centre_name: return all_results +async def get_all_zone_source_ids(session: AsyncSession): + """ + Retrieve the ids of all fire shapes (aka fire zone units). + + :param session: An async database session. + :return: A list of the ids of all fire shapes/fire zone units. + """ + logger.info("retrieving all fire zone source ids from advisory_shapes table") + stmt = select(Shape.source_identifier) + result = await session.execute(stmt) + return result.scalars().all() + + async def get_all_sfms_fuel_type_records(session: AsyncSession) -> List[SFMSFuelType]: """ Retrieve all records from the sfms_fuel_types table. @@ -407,6 +421,37 @@ async def get_most_recent_run_datetime_for_date(session: AsyncSession, for_date: return result.scalars().first() +async def get_most_recent_run_datetime_for_date_range( + session: AsyncSession, start_date: date, end_date: date +): + """ + Return the most recent SFMS run parameters for each date from the start_date to the end_date (inclusive). + + :param session: An async database session. + :param start_date: The start date. + :param end_date: The end date. + :return: A list of the most recent SFMS run parameter per date within the specified range. + """ + subquery = ( + select( + RunParameters, + func.row_number() + .over(partition_by=RunParameters.for_date, order_by=desc(RunParameters.run_datetime)) + .label("row_num"), + ) + .where(RunParameters.for_date.between(start_date, end_date)) + .subquery() + ) + + # Alias the subquery for querying + run_params_alias = aliased(RunParameters, subquery) + + # Final query: only rows with row_num == 1 + stmt = select(run_params_alias).where(subquery.c.row_num == 1) + result = await session.execute(stmt) + return result.scalars() + + async def get_sfms_bounds(session: AsyncSession): stmt = ( select( @@ -677,7 +722,7 @@ async def get_centre_tpi_stats( run_type: RunType, run_datetime: datetime, for_date: date, -) -> AdvisoryTPIStats: +): run_parameters_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) stmt = ( @@ -701,6 +746,41 @@ async def get_centre_tpi_stats( return result.all() +async def get_tpi_stats( + session: AsyncSession, run_type: RunType, run_datetime: datetime, for_date: date +): + """ + Return the TPI stats for all fire zone units for the SFMS run parameter corresponding to the provided run type, for date and run date time. + + :param session: An async database session. + :param run_type: The RunType. + :param run_datetime: The date and time of the SFMS run. + :param for_date: The for date of the SFMS run. + :return: TPI fuel stats for all fire zone units. + """ + run_parameters_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) + stmt = ( + select( + AdvisoryTPIStats.advisory_shape_id, + Shape.source_identifier, + AdvisoryTPIStats.valley_bottom, + AdvisoryTPIStats.mid_slope, + AdvisoryTPIStats.upper_slope, + AdvisoryTPIStats.pixel_size_metres, + FireCentre.id, + FireCentre.name, + ) + .join(Shape, Shape.id == AdvisoryTPIStats.advisory_shape_id) + .join(FireCentre, FireCentre.id == Shape.fire_centre) + .where( + AdvisoryTPIStats.run_parameters == run_parameters_id, + ) + ) + + result = await session.execute(stmt) + return result.all() + + async def get_fire_centre_tpi_fuel_areas( session: AsyncSession, fire_centre_name: str, fuel_type_raster_id: int ): @@ -717,6 +797,32 @@ async def get_fire_centre_tpi_fuel_areas( return result.all() +async def get_tpi_fuel_areas(session: AsyncSession, fuel_type_raster_id: int): + """ + Retrieve TPI fuel stats for all fire zone units in the province. + + :param session: An async database session. + :param fuel_type_raster_id: The fuel grid raster id. + :return: The TPI fuel stats for all fire zone units. + """ + stmt = ( + select( + TPIFuelArea.tpi_class, + TPIFuelArea.fuel_area, + Shape.source_identifier, + FireCentre.id, + FireCentre.name, + ) + .join(Shape, Shape.id == TPIFuelArea.advisory_shape_id) + .join(FireCentre, FireCentre.id == Shape.fire_centre) + .where( + TPIFuelArea.fuel_type_raster_id == fuel_type_raster_id, + ) + ) + result = await session.execute(stmt) + return result.all() + + async def get_provincial_rollup( session: AsyncSession, run_type: RunTypeEnum, diff --git a/wps_shared/wps_shared/schemas/fba.py b/wps_shared/wps_shared/schemas/fba.py index b1f266d905..6d9a5c046c 100644 --- a/wps_shared/wps_shared/schemas/fba.py +++ b/wps_shared/wps_shared/schemas/fba.py @@ -1,7 +1,7 @@ """This module contains pydantic models related to the new formal/non-tinker fba.""" from datetime import date, datetime -from typing import List, Optional +from typing import Dict, List, Optional from pydantic import BaseModel @@ -130,6 +130,12 @@ class FireZoneHFIStats(BaseModel): fuel_area_stats: List[ClassifiedHfiThresholdFuelTypeArea] +class HFIStatsResponse(BaseModel): + """HFI Stats for all zones for a run parameter""" + + zone_data: Dict[int, FireZoneHFIStats] + + class FireZoneElevationStats(BaseModel): """Basic elevation statistics for a firezone""" @@ -152,11 +158,14 @@ class FireZoneTPIStats(BaseModel): upper_slope_tpi: Optional[float] -class FireCentreTPIResponse(BaseModel): - fire_centre_name: str +class TPIResponse(BaseModel): firezone_tpi_stats: List[FireZoneTPIStats] +class FireCentreTPIResponse(TPIResponse): + fire_centre_name: str + + class FireZoneElevationStatsByThreshold(BaseModel): """Elevation statistics for a firezone by threshold""" @@ -182,3 +191,12 @@ class LatestSFMSRunParameter(BaseModel): class LatestSFMSRunParameterResponse(BaseModel): run_parameter: Optional[LatestSFMSRunParameter] = None + +class SFMSRunParameter(BaseModel): + for_date: date + run_type: SFMSRunType + run_datetime: datetime + + +class LatestSFMSRunParameterRangeResponse(BaseModel): + run_parameters: Dict[date, SFMSRunParameter]