From b8762ad90dcb301945726e404f304ed794077827 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 21 Aug 2025 08:44:17 -0700 Subject: [PATCH 01/40] Caching for run parameters, provincial summary and fire shape areas --- mobile/asa-go/src/api/fbaAPI.ts | 10 + mobile/asa-go/src/components/map/ASAGoMap.tsx | 24 +- .../asa-go/src/components/profile/Profile.tsx | 1 + .../asa-go/src/components/report/Advisory.tsx | 2 + .../src/components/report/AdvisoryText.tsx | 45 ++- .../components/report/FireZoneUnitTabs.tsx | 8 +- .../asa-go/src/hooks/useFireCentreDetails.ts | 11 +- .../src/hooks/useFireShapeAreasForDate.ts | 29 ++ .../src/hooks/useProvincialSummaryForDate.ts | 25 ++ .../src/hooks/useRunParameterForDate.ts | 24 ++ mobile/asa-go/src/rootReducer.ts | 20 +- .../asa-go/src/slices/authenticationSlice.ts | 8 +- mobile/asa-go/src/slices/dataSlice.ts | 322 ++++++++++++++++++ mobile/asa-go/src/slices/fireCentersSlice.ts | 61 +++- .../asa-go/src/slices/runParametersSlice.ts | 177 ++++++++++ mobile/asa-go/src/store.ts | 8 +- .../asa-go/src/utils/calculateZoneStatus.ts | 2 +- mobile/asa-go/src/utils/storage.ts | 94 +++++ 18 files changed, 807 insertions(+), 64 deletions(-) create mode 100644 mobile/asa-go/src/hooks/useFireShapeAreasForDate.ts create mode 100644 mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts create mode 100644 mobile/asa-go/src/hooks/useRunParameterForDate.ts create mode 100644 mobile/asa-go/src/slices/dataSlice.ts create mode 100644 mobile/asa-go/src/slices/runParametersSlice.ts create mode 100644 mobile/asa-go/src/utils/storage.ts diff --git a/mobile/asa-go/src/api/fbaAPI.ts b/mobile/asa-go/src/api/fbaAPI.ts index 569cdbfc52..cb9678531b 100644 --- a/mobile/asa-go/src/api/fbaAPI.ts +++ b/mobile/asa-go/src/api/fbaAPI.ts @@ -136,6 +136,10 @@ export interface RunParameter { run_type: RunType } +export interface RunParametersResponse { + [key: string]: RunParameter +} + const ASA_GO_API_PREFIX = "fba" export async function getFBAFireCenters(): Promise { @@ -172,6 +176,12 @@ export async function getMostRecentRunParameter(forDate: 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, diff --git a/mobile/asa-go/src/components/map/ASAGoMap.tsx b/mobile/asa-go/src/components/map/ASAGoMap.tsx index d31ba239d4..f6c8984db3 100644 --- a/mobile/asa-go/src/components/map/ASAGoMap.tsx +++ b/mobile/asa-go/src/components/map/ASAGoMap.tsx @@ -21,6 +21,8 @@ import { fireShapeStyler, } from "@/featureStylers"; import { fireZoneExtentsMap } from "@/fireZoneUnitExtents"; +import { useFireShapeAreasForDate } from "@/hooks/useFireShapeAreasForDate"; +import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { createBasemapLayer, createHFILayer, @@ -30,13 +32,7 @@ import { HFI_LAYER_NAME, } from "@/layerDefinitions"; import { startWatchingLocation } from "@/slices/geolocationSlice"; -import { - AppDispatch, - selectFireShapeAreas, - selectGeolocation, - selectNetworkStatus, - selectRunParameter, -} from "@/store"; +import { AppDispatch, selectGeolocation, selectNetworkStatus } from "@/store"; import { CENTER_OF_BC, NavPanel } from "@/utils/constants"; import { PMTilesCache } from "@/utils/pmtilesCache"; import { PMTilesFileVectorSource } from "@/utils/pmtilesVectorSource"; @@ -104,9 +100,11 @@ 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); const [scaleVisible, setScaleVisible] = useState(true); @@ -484,20 +482,20 @@ const ASAGoMap = ({ (async () => { let hfiLayer: VectorTileLayer | null = null; - if (!isNull(runType) && !isNull(runDatetime)) { + if (!isNull(runParameter?.run_type) && !isNull(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/profile/Profile.tsx b/mobile/asa-go/src/components/profile/Profile.tsx index 1d3b385190..56453281e3 100644 --- a/mobile/asa-go/src/components/profile/Profile.tsx +++ b/mobile/asa-go/src/components/profile/Profile.tsx @@ -116,6 +116,7 @@ const Profile = ({ 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..0677f03699 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -5,9 +5,9 @@ import { FireZoneHFIStats, } from "@/api/fbaAPI"; import DefaultText from "@/components/report/DefaultText"; +import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { selectFilteredFireCentreHFIFuelStats } from "@/slices/fireCentreHFIFuelStatsSlice"; -import { selectProvincialSummary } from "@/slices/provincialSummarySlice"; -import { selectForDate, selectRunDatetime } from "@/slices/runParameterSlice"; +import { useProvincialSummaryForDate } from "@/hooks/useProvincialSummaryForDate"; import { getTopFuelsByArea, getTopFuelsByProportion, @@ -21,7 +21,7 @@ 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"; @@ -35,22 +35,22 @@ 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 provincialSummary = useProvincialSummaryForDate(date); const filteredFireCentreHFIFuelStats = useSelector( selectFilteredFireCentreHFIFuelStats ); - - const forDate = useSelector(selectForDate); - const runDatetime = useSelector(selectRunDatetime); + const runParameter = useRunParameterForDate(date); // derived state const selectedFilteredZoneUnitFuelStats = useMemo(() => { @@ -58,7 +58,8 @@ const AdvisoryText = ({ isUndefined(filteredFireCentreHFIFuelStats) || isEmpty(filteredFireCentreHFIFuelStats) || isUndefined(selectedFireCenter) || - isUndefined(selectedFireZoneUnit) + isUndefined(selectedFireZoneUnit) || + isNil(runParameter) ) { return { fuel_area_stats: [], min_wind_stats: [] }; } @@ -73,11 +74,14 @@ const AdvisoryText = ({ }, [filteredFireCentreHFIFuelStats, 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 +99,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 ); @@ -230,10 +234,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 === DateTime.now().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 +298,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 +368,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/hooks/useFireCentreDetails.ts b/mobile/asa-go/src/hooks/useFireCentreDetails.ts index a3a184355e..3e08a7f289 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/useProvincialSummaryForDate"; 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/useFireShapeAreasForDate.ts b/mobile/asa-go/src/hooks/useFireShapeAreasForDate.ts new file mode 100644 index 0000000000..1db55a8cb4 --- /dev/null +++ b/mobile/asa-go/src/hooks/useFireShapeAreasForDate.ts @@ -0,0 +1,29 @@ +import { FireShapeArea } from "@/api/fbaAPI"; +import { selectFireShapeAreas } from "@/store"; +import { isNil } from "lodash"; +import { DateTime } from "luxon"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +/** + * 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]); +}; diff --git a/mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts b/mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts new file mode 100644 index 0000000000..73d477e415 --- /dev/null +++ b/mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts @@ -0,0 +1,25 @@ +import { FireShapeAreaDetail } from "@/api/fbaAPI"; +import { selectProvincialSummaries } from "@/store"; +import { Dictionary, groupBy, isNil } from "lodash"; +import { DateTime } from "luxon"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +/** + * 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]); +}; \ No newline at end of file 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..1ab4cab98e 100644 --- a/mobile/asa-go/src/rootReducer.ts +++ b/mobile/asa-go/src/rootReducer.ts @@ -1,13 +1,14 @@ -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 fireCentreHFIFuelStatsSlice from "@/slices/fireCentreHFIFuelStatsSlice"; +import fireCentreTPIStatsSlice from "@/slices/fireCentreTPIStatsSlice"; +import fireShapeAreasSlice from "@/slices/fireZoneAreasSlice"; +import fireZoneElevationInfoSlice from "@/slices/fireZoneElevationInfoSlice"; import geolocationSlice from "@/slices/geolocationSlice"; -import authenticateSlice from "@/slices/authenticationSlice"; +import networkStatusSlice from "@/slices/networkStatusSlice"; +import provincialSummarySlice from "@/slices/provincialSummarySlice"; +import runParametersSlice from "@/slices/runParametersSlice"; import { combineReducers } from "@reduxjs/toolkit"; export const rootReducer = combineReducers({ @@ -19,6 +20,7 @@ export const rootReducer = combineReducers({ fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice, networkStatus: networkStatusSlice, geolocation: geolocationSlice, - runParameter: runParameterSlice, + runParameters: runParametersSlice, authentication: authenticateSlice, + data: dataSlice, }); diff --git a/mobile/asa-go/src/slices/authenticationSlice.ts b/mobile/asa-go/src/slices/authenticationSlice.ts index aafa606d31..e7353cffdf 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.ts @@ -58,12 +58,10 @@ const authSlice = createSlice({ state.idToken = action.payload.idToken; state.tokenRefreshed = action.payload.tokenRefreshed; }, - resetAuthentication( - state: AuthState - ) { + resetAuthentication(state: AuthState) { state.isAuthenticated = false; - state.idToken = undefined - state.token = undefined + state.idToken = undefined; + state.token = undefined; }, }, }); diff --git a/mobile/asa-go/src/slices/dataSlice.ts b/mobile/asa-go/src/slices/dataSlice.ts new file mode 100644 index 0000000000..21a66be41d --- /dev/null +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -0,0 +1,322 @@ +import { AppThunk } from "@/store"; +import { + CacheableData, + CacheableDataType, + FIRE_SHAPE_AREAS_KEY, + PROVINCIAL_SUMMARY_KEY, + readFromFilesystem, + writeToFileSystem, +} from "@/utils/storage"; +import { Filesystem } from "@capacitor/filesystem"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { + FireShapeArea, + FireShapeAreaDetail, + getFireShapeAreas, + getProvincialSummary, + RunParameter, +} from "api/fbaAPI"; +import { isEqual, isNil } from "lodash"; +import { DateTime } from "luxon"; + +export interface DataState { + loading: boolean; + error: string | null; + lastUpdated: string | null; + fireShapeAreas: CacheableData | null; + provincialSummaries: CacheableData | null; +} + +export const initialState: DataState = { + loading: false, + error: null, + lastUpdated: null, + provincialSummaries: null, + fireShapeAreas: 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; + }> + ) { + state.error = null; + state.lastUpdated = action.payload.lastUpdated; + state.fireShapeAreas = action.payload.fireShapeAreas; + state.provincialSummaries = action.payload.provincialSummaries; + state.loading = false; + }, + }, +}); + +export const { getDataStart, getDataFailed, getDataSuccess } = + dataSlice.actions; + +export default dataSlice.reducer; + +export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { + const today = DateTime.now(); + const tomorrow = today.plus({ days: 1 }); + const todayKey = today.toISODate(); + const tomorrowKey = tomorrow.toISODate(); + 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)) { + // Run parameters are required to fetch data. Should this be an error state? + 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 + ); + isCurrent = + !isNil(cachedProvincialSummaries?.data) && + runParametersMatch( + todayKey, + tomorrowKey, + runParameters, + cachedProvincialSummaries.data + ); + + const cachedFireShapeAreas = await readFromFilesystem( + Filesystem, + FIRE_SHAPE_AREAS_KEY + ); + isCurrent = + !isNil(cachedFireShapeAreas?.data) && + runParametersMatch( + todayKey, + tomorrowKey, + runParameters, + cachedFireShapeAreas.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; + if ( + !dataAreEqual(stateProvincialSummaries, cachedProvincialSummaries.data) && + !dataAreEqual(stateFireShapeAreas, cachedFireShapeAreas.data) + ) { + dispatch( + getDataSuccess({ + lastUpdated: DateTime.now().toISODate(), + fireShapeAreas: cachedFireShapeAreas.data, + provincialSummaries: cachedProvincialSummaries.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 + ); + + // 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 + ); + + // Update state + dispatch( + getDataSuccess({ + lastUpdated: DateTime.now().toISODate(), + fireShapeAreas: fireShapeAreas, + provincialSummaries, + }) + ); + return; + } catch (err) { + dispatch(getDataFailed((err as Error).toString())); + console.error(err); + } + } + dispatch(getDataFailed("Unable to update data. Data may be stale.")); +}; + +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) + ); +}; + +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; +}; + +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; +}; + +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, + }, + }; +}; + +const fetchFireShapeArea = async (runParameter: RunParameter) => { + const fireShapeArea = await getFireShapeAreas( + runParameter.run_type, + runParameter.run_datetime, + runParameter.for_date + ); + return fireShapeArea?.shapes; +}; + +const fetchProvincialSummary = async (runParameter: RunParameter) => { + const provincialSummary = await getProvincialSummary( + runParameter.run_type, + runParameter.run_datetime, + runParameter.for_date + ); + return provincialSummary?.provincial_summary; +}; + +const dataAreEqual = ( + a: CacheableData | null, + b: CacheableData | null +): boolean => { + return isEqual(a, b); +}; + +// const selectFireShapeAreaDetails = (state: RootState) => state.data; + +// export const selectProvincialSummary = createSelector( +// [selectFireShapeAreaDetails], +// (fireShapeAreaDetails) => { +// const groupedByFireCenter = groupBy( +// fireShapeAreaDetails.fireShapeAreaDetails, +// "fire_centre_name" +// ); +// return groupedByFireCenter; +// } +// ); + +// const fetchAndCacheProvincialSummaries = 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 +// const cacheableSummaries = { +// [todayKey]: { +// runParameter: runParameters[todayKey], +// data: todayProvincialSummary, +// }, +// [tomorrowKey]: { +// runParameter: runParameters[tomorrowKey], +// data: tomorrowProvincialSummary, +// }, +// }; +// // Cache the data +// await writeToFileSystem(Filesystem, PROVINCIAL_SUMMARY_KEY, cacheableSummaries, DateTime.now()) +// return cacheableSummaries +// }; diff --git a/mobile/asa-go/src/slices/fireCentersSlice.ts b/mobile/asa-go/src/slices/fireCentersSlice.ts index e6a0c9ef0a..0320005631 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 { 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 { FireCenter, getFBAFireCenters } from "api/fbaAPI"; +import { isNull } from "lodash"; +import { DateTime } from "luxon"; export interface FireCentresState { loading: boolean; @@ -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) => { + const now = DateTime.now(); + // 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 + ); + if (!isNull(cachedFireCenters)) { + const lastUpdated = DateTime.fromISO(cachedFireCenters.lastUpdated); + if (lastUpdated.plus({ hours: FIRE_CENTERS_CACHE_EXPIRATION }) > now) { + dispatch(getFireCentersSuccess(cachedFireCenters.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.networkStatus.connected) { + try { + dispatch(getFireCentersStart()); + const fireCenters = await getFBAFireCenters(); + await writeToFileSystem( + Filesystem, + FIRE_CENTERS_KEY, + fireCenters.fire_centers, + now + ); + 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/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts new file mode 100644 index 0000000000..65805849b0 --- /dev/null +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -0,0 +1,177 @@ +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"; +import { DateTime } from "luxon"; + +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 now = DateTime.now(); + const todayKey = now.toISODate(); + const tomorrowKey = now.plus({ days: 1 }).toISODate(); + 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, + now + ); + + if ( + isNil(reduxRunParameters) || + stateUpdateRequired( + todayKey, + tomorrowKey, + reduxRunParameters, + latestRunParameters + ) + ) { + // Retrieved run parameters differ from redux state so update + dispatch( + getRunParametersSuccess({ + runParameters: latestRunParameters, + }) + ); + } + } + } catch (err) { + dispatch(getRunParametersFailed((err as Error).toString())); + console.log(err); + } + } 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 } = !isNil( + cachedData + ) + ? cachedData.data + : null; + 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, + }) + ); + } else { + // 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 + ); +}; \ No newline at end of file diff --git a/mobile/asa-go/src/store.ts b/mobile/asa-go/src/store.ts index 87c7627cb7..ad4c6f5bec 100644 --- a/mobile/asa-go/src/store.ts +++ b/mobile/asa-go/src/store.ts @@ -12,8 +12,6 @@ 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; @@ -21,3 +19,9 @@ 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; 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/storage.ts b/mobile/asa-go/src/utils/storage.ts new file mode 100644 index 0000000000..3a15a72f55 --- /dev/null +++ b/mobile/asa-go/src/utils/storage.ts @@ -0,0 +1,94 @@ +import { + FireCenter, + FireCentreTPIResponse, + FireShapeArea, + FireShapeAreaDetail, + RunParameter, +} from "@/api/fbaAPI"; +import { Directory, Encoding, FilesystemPlugin } from "@capacitor/filesystem"; +import { DateTime } from "luxon"; + +export type CacheableDataType = FireShapeAreaDetail[] | FireShapeArea[] + +export type CacheableData = {[key: string]: { + runParameter: RunParameter, + data: T +}} + +type Cacheable = FireShapeAreaDetail[] | FireCentreTPIResponse | FireCenter[] | { [key: string]: RunParameter } + +const CACHE_KEY = "_asa_go"; +export const FIRE_CENTERS_KEY = "fireCenters" +export const FIRE_SHAPE_AREAS_KEY = "fireShapeAreas" +export const PROVINCIAL_SUMMARY_KEY = "provincialSummary" +export const RUN_PARAMETERS_CACHE_KEY = "runParameters" +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 +) => { + 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 deleteStaleCache = async ( + filesystem: FilesystemPlugin, + date: DateTime +) => { + const readdirResult = await filesystem.readdir({ + path: "/", + directory: Directory.Data, + }); + + for (const file of readdirResult.files) { + if (file.name.startsWith(CACHE_KEY)) { + try { + const result = await filesystem.readFile({ + path: file.name, + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + const parsedData = JSON.parse(result.data as string); + const parsedTimeStamp = DateTime.fromISO(parsedData.timeStamp); + if (parsedTimeStamp < date) { + await filesystem.deleteFile({ + path: file.uri, + directory: Directory.Data, + }); + } + } catch { + // Ignore files if they can't be read or parsed + } + } + } +}; From 6fe321c0425095156745d9c34b4914f6379c9618 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 25 Aug 2025 11:19:50 -0700 Subject: [PATCH 02/40] Progress --- mobile/asa-go/src/App.tsx | 77 +++---- mobile/asa-go/src/api/fbaAPI.ts | 74 +++++-- .../profile/FireZoneUnitSummary.tsx | 30 +-- .../asa-go/src/components/profile/Profile.tsx | 1 + .../src/components/report/AdvisoryText.tsx | 23 +-- mobile/asa-go/src/hooks/useTpiStatsForDate.ts | 27 +++ mobile/asa-go/src/rootReducer.ts | 10 - mobile/asa-go/src/slices/dataSlice.ts | 194 +++++++++++++----- .../src/slices/fireCentreHFIFuelStatsSlice.ts | 9 +- mobile/asa-go/src/slices/runParameterSlice.ts | 104 ---------- .../asa-go/src/slices/runParametersSlice.ts | 14 +- mobile/asa-go/src/store.ts | 4 +- mobile/asa-go/src/utils/hfiStatsUtils.ts | 21 +- mobile/asa-go/src/utils/storage.ts | 68 +++--- .../db/crud/auto_spatial_advisory.py | 114 +++++++++- 15 files changed, 433 insertions(+), 337 deletions(-) create mode 100644 mobile/asa-go/src/hooks/useTpiStatsForDate.ts delete mode 100644 mobile/asa-go/src/slices/runParameterSlice.ts diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index 31561f2d97..5f0adb2d26 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -4,26 +4,24 @@ 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 { 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 } from "@/store"; import { theme } from "@/theme"; import { NavPanel, PST_UTC_OFFSET } from "@/utils/constants"; 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 +30,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 +41,16 @@ const App = () => { const [selectedFireShape, setSelectedFireShape] = useState< FireShape | undefined >(undefined); - const [dateOfInterest, setDateOfInterest] = useState( + const [dateOfInterest, setDateOfInterest] = useState( DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`) ); - const { runDatetime, runType } = useSelector(selectRunParameter); + + // selected redux state + const { fireCenters } = useSelector(selectFireCenters); + const { networkStatus } = useSelector(selectNetworkStatus); + + // hooks + const runParameter = useRunParameterForDate(dateOfInterest); useEffect(() => { // Network status is disconnected by default in the networkStatusSlice. Update the status @@ -68,20 +72,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 +106,10 @@ 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 - ) - ); - } - }, [fireCenter, runDatetime]); // eslint-disable-line react-hooks/exhaustive-deps - - 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 cb9678531b..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,17 +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 + [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`; @@ -154,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; } @@ -165,18 +178,25 @@ 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 { +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; @@ -193,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, @@ -213,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/profile/FireZoneUnitSummary.tsx b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx index f8c750d534..f374f5584e 100644 --- a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx +++ b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx @@ -1,42 +1,42 @@ +import ElevationStatus from "@/components/profile/ElevationStatus"; +import FuelSummary from "@/components/profile/FuelSummary"; +import { useFilteredHFIStatsForDate } from "@/hooks/dataHooks"; +import { useTPIStatsForDate } from "@/hooks/useTpiStatsForDate"; +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 56453281e3..48e7ca1f2f 100644 --- a/mobile/asa-go/src/components/profile/Profile.tsx +++ b/mobile/asa-go/src/components/profile/Profile.tsx @@ -121,6 +121,7 @@ const Profile = ({ )} diff --git a/mobile/asa-go/src/components/report/AdvisoryText.tsx b/mobile/asa-go/src/components/report/AdvisoryText.tsx index 0677f03699..d51a30a0d3 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -5,9 +5,9 @@ import { FireZoneHFIStats, } from "@/api/fbaAPI"; import DefaultText from "@/components/report/DefaultText"; -import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; -import { selectFilteredFireCentreHFIFuelStats } from "@/slices/fireCentreHFIFuelStatsSlice"; +import { useFilteredHFIStatsForDate } from "@/hooks/dataHooks"; import { useProvincialSummaryForDate } from "@/hooks/useProvincialSummaryForDate"; +import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { getTopFuelsByArea, getTopFuelsByProportion, @@ -24,7 +24,6 @@ import { Box, styled, Typography, useTheme } from "@mui/material"; 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", @@ -45,33 +44,29 @@ const AdvisoryText = ({ date, }: AdvisoryTextProps) => { const theme = useTheme(); - // selectors + + // hooks const provincialSummary = useProvincialSummaryForDate(date); - const filteredFireCentreHFIFuelStats = useSelector( - selectFilteredFireCentreHFIFuelStats - ); + const filteredFireZoneUnitHFIStats = useFilteredHFIStatsForDate(date); const runParameter = useRunParameterForDate(date); // derived state const selectedFilteredZoneUnitFuelStats = useMemo(() => { if ( - isUndefined(filteredFireCentreHFIFuelStats) || - isEmpty(filteredFireCentreHFIFuelStats) || - isUndefined(selectedFireCenter) || + 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 (isNil(runParameter?.for_date)) { diff --git a/mobile/asa-go/src/hooks/useTpiStatsForDate.ts b/mobile/asa-go/src/hooks/useTpiStatsForDate.ts new file mode 100644 index 0000000000..56126227c2 --- /dev/null +++ b/mobile/asa-go/src/hooks/useTpiStatsForDate.ts @@ -0,0 +1,27 @@ +import { FireZoneTPIStats } from "@/api/fbaAPI"; +import { selectTPIStats } from "@/store"; +import { isNil } from "lodash"; +import { DateTime } from "luxon"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +/** + * 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]); +}; diff --git a/mobile/asa-go/src/rootReducer.ts b/mobile/asa-go/src/rootReducer.ts index 1ab4cab98e..d706957b34 100644 --- a/mobile/asa-go/src/rootReducer.ts +++ b/mobile/asa-go/src/rootReducer.ts @@ -1,23 +1,13 @@ import authenticateSlice from "@/slices/authenticationSlice"; import dataSlice from "@/slices/dataSlice"; import fireCentersSlice from "@/slices/fireCentersSlice"; -import fireCentreHFIFuelStatsSlice from "@/slices/fireCentreHFIFuelStatsSlice"; -import fireCentreTPIStatsSlice from "@/slices/fireCentreTPIStatsSlice"; -import fireShapeAreasSlice from "@/slices/fireZoneAreasSlice"; -import fireZoneElevationInfoSlice from "@/slices/fireZoneElevationInfoSlice"; import geolocationSlice from "@/slices/geolocationSlice"; import networkStatusSlice from "@/slices/networkStatusSlice"; -import provincialSummarySlice from "@/slices/provincialSummarySlice"; 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, runParameters: runParametersSlice, diff --git a/mobile/asa-go/src/slices/dataSlice.ts b/mobile/asa-go/src/slices/dataSlice.ts index 21a66be41d..d6e5d707b9 100644 --- a/mobile/asa-go/src/slices/dataSlice.ts +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -3,8 +3,10 @@ import { CacheableData, CacheableDataType, FIRE_SHAPE_AREAS_KEY, + HFI_STATS_KEY, PROVINCIAL_SUMMARY_KEY, readFromFilesystem, + TPI_STATS_KEY, writeToFileSystem, } from "@/utils/storage"; import { Filesystem } from "@capacitor/filesystem"; @@ -12,8 +14,12 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { FireShapeArea, FireShapeAreaDetail, + FireZoneHFIStatsDictionary, + FireZoneTPIStats, getFireShapeAreas, + getHFIStats, getProvincialSummary, + getTPIStats, RunParameter, } from "api/fbaAPI"; import { isEqual, isNil } from "lodash"; @@ -25,6 +31,8 @@ export interface DataState { lastUpdated: string | null; fireShapeAreas: CacheableData | null; provincialSummaries: CacheableData | null; + tpiStats: CacheableData | null; + hfiStats: CacheableData | null; } export const initialState: DataState = { @@ -33,6 +41,8 @@ export const initialState: DataState = { lastUpdated: null, provincialSummaries: null, fireShapeAreas: null, + tpiStats: null, + hfiStats: null, }; const dataSlice = createSlice({ @@ -52,13 +62,17 @@ const dataSlice = createSlice({ action: PayloadAction<{ lastUpdated: string; fireShapeAreas: CacheableData | null; - provincialSummaries: CacheableData; + 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; }, }, @@ -109,19 +123,45 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { cachedFireShapeAreas.data ); + const cachedTPIStats = await readFromFilesystem(Filesystem, TPI_STATS_KEY); + isCurrent = + !isNil(cachedTPIStats?.data) && + runParametersMatch( + todayKey, + tomorrowKey, + runParameters, + cachedTPIStats.data + ); + + const cachedHFIStats = await readFromFilesystem(Filesystem, HFI_STATS_KEY); + 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(stateFireShapeAreas, cachedFireShapeAreas.data) && + !dataAreEqual(stateTPIStats, cachedTPIStats.data) && + !dataAreEqual(stateHFIStats, cachedHFIStats.data) ) { dispatch( getDataSuccess({ lastUpdated: DateTime.now().toISODate(), fireShapeAreas: cachedFireShapeAreas.data, provincialSummaries: cachedProvincialSummaries.data, + tpiStats: cachedTPIStats.data, + hfiStats: cachedHFIStats.data, }) ); } @@ -143,6 +183,16 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { 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 @@ -152,13 +202,14 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { 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( @@ -166,15 +217,17 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { lastUpdated: DateTime.now().toISODate(), fireShapeAreas: fireShapeAreas, provincialSummaries, + tpiStats, + hfiStats, }) ); - return; } catch (err) { dispatch(getDataFailed((err as Error).toString())); console.error(err); } + } else { + dispatch(getDataFailed("Unable to update data. Data may be stale.")); } - dispatch(getDataFailed("Unable to update data. Data may be stale.")); }; const runParametersMatch = ( @@ -184,8 +237,8 @@ const runParametersMatch = ( data: CacheableData ): boolean => { return ( - isEqual(runParameters[todayKey], data[todayKey].runParameter) && - isEqual(runParameters[tomorrowKey], data[tomorrowKey].runParameter) + isEqual(runParameters[todayKey], data[todayKey]?.runParameter) && + isEqual(runParameters[tomorrowKey], data[tomorrowKey]?.runParameter) ); }; @@ -206,7 +259,49 @@ const fetchFireShapeAreas = async ( todayFireShapeArea, tomorrowFireShapeArea ); - return fireShapeAreas; + return fireShapeAreas as CacheableData; +}; + +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; +}; + +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; }; const fetchProvincialSummaries = async ( @@ -255,7 +350,12 @@ const shapeDataForCaching = ( }; }; -const fetchFireShapeArea = async (runParameter: RunParameter) => { +const fetchFireShapeArea = async ( + runParameter: RunParameter +): Promise => { + if (isNil(runParameter)) { + return []; + } const fireShapeArea = await getFireShapeAreas( runParameter.run_type, runParameter.run_datetime, @@ -264,7 +364,12 @@ const fetchFireShapeArea = async (runParameter: RunParameter) => { return fireShapeArea?.shapes; }; -const fetchProvincialSummary = async (runParameter: RunParameter) => { +const fetchProvincialSummary = async ( + runParameter: RunParameter +): Promise => { + if (isNil(runParameter)) { + return []; + } const provincialSummary = await getProvincialSummary( runParameter.run_type, runParameter.run_datetime, @@ -273,50 +378,37 @@ const fetchProvincialSummary = async (runParameter: RunParameter) => { return provincialSummary?.provincial_summary; }; +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; +}; + +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; +}; + const dataAreEqual = ( a: CacheableData | null, b: CacheableData | null ): boolean => { return isEqual(a, b); }; - -// const selectFireShapeAreaDetails = (state: RootState) => state.data; - -// export const selectProvincialSummary = createSelector( -// [selectFireShapeAreaDetails], -// (fireShapeAreaDetails) => { -// const groupedByFireCenter = groupBy( -// fireShapeAreaDetails.fireShapeAreaDetails, -// "fire_centre_name" -// ); -// return groupedByFireCenter; -// } -// ); - -// const fetchAndCacheProvincialSummaries = 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 -// const cacheableSummaries = { -// [todayKey]: { -// runParameter: runParameters[todayKey], -// data: todayProvincialSummary, -// }, -// [tomorrowKey]: { -// runParameter: runParameters[tomorrowKey], -// data: tomorrowProvincialSummary, -// }, -// }; -// // Cache the data -// await writeToFileSystem(Filesystem, PROVINCIAL_SUMMARY_KEY, cacheableSummaries, DateTime.now()) -// return cacheableSummaries -// }; diff --git a/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts b/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts index e0111d483a..573d990515 100644 --- a/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts +++ b/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts @@ -1,7 +1,6 @@ -import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { 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 { @@ -71,9 +70,3 @@ export const fetchFireCentreHFIFuelStats = export const selectFireCentreHFIFuelStats = (state: RootState) => state.fireCentreHFIFuelStats; - -export const selectFilteredFireCentreHFIFuelStats = createSelector( - [selectFireCentreHFIFuelStats], - (fireCentreHFIFuelStats) => - filterHFIFuelStatsByArea(fireCentreHFIFuelStats.fireCentreHFIFuelStats) -); 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.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index 65805849b0..630e521dd3 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -141,14 +141,14 @@ export const fetchSFMSRunParameters = } }; - -export const selectRunParameters = (state: RootState) => state.runParameters.runParameters +export const selectRunParameters = (state: RootState) => + state.runParameters.runParameters; export const selectRunParameterByForDate = (forDate: string) => { - createSelector( - [selectRunParameters], (runParameters) => { - return isNil(runParameters) ? null : runParameters[forDate] - })} + createSelector([selectRunParameters], (runParameters) => { + return isNil(runParameters) ? null : runParameters[forDate]; + }); +}; const stateUpdateRequired = ( todayKey: string, @@ -174,4 +174,4 @@ const runParametersAreEqual = (a: RunParameter, b: RunParameter) => { a.run_datetime === b.run_datetime && a.run_type === b.run_type ); -}; \ No newline at end of file +}; diff --git a/mobile/asa-go/src/store.ts b/mobile/asa-go/src/store.ts index ad4c6f5bec..7539198c66 100644 --- a/mobile/asa-go/src/store.ts +++ b/mobile/asa-go/src/store.ts @@ -16,8 +16,6 @@ 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; @@ -25,3 +23,5 @@ 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/hfiStatsUtils.ts b/mobile/asa-go/src/utils/hfiStatsUtils.ts index 5048ce9780..dd93abc2f7 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[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/storage.ts b/mobile/asa-go/src/utils/storage.ts index 3a15a72f55..838c6a211b 100644 --- a/mobile/asa-go/src/utils/storage.ts +++ b/mobile/asa-go/src/utils/storage.ts @@ -3,26 +3,40 @@ import { FireCentreTPIResponse, FireShapeArea, FireShapeAreaDetail, + FireZoneHFIStatsDictionary, + FireZoneTPIStats, RunParameter, } from "@/api/fbaAPI"; import { Directory, Encoding, FilesystemPlugin } from "@capacitor/filesystem"; import { DateTime } from "luxon"; -export type CacheableDataType = FireShapeAreaDetail[] | FireShapeArea[] +export type CacheableDataType = + | FireShapeAreaDetail[] + | FireShapeArea[] + | FireZoneTPIStats[] + | FireZoneHFIStatsDictionary; -export type CacheableData = {[key: string]: { - runParameter: RunParameter, - data: T -}} +export type CacheableData = { + [key: string]: { + runParameter: RunParameter; + data: T; + }; +}; -type Cacheable = FireShapeAreaDetail[] | FireCentreTPIResponse | FireCenter[] | { [key: string]: RunParameter } +type Cacheable = + | FireShapeAreaDetail[] + | FireCentreTPIResponse + | FireCenter[] + | { [key: string]: RunParameter }; const CACHE_KEY = "_asa_go"; -export const FIRE_CENTERS_KEY = "fireCenters" -export const FIRE_SHAPE_AREAS_KEY = "fireShapeAreas" -export const PROVINCIAL_SUMMARY_KEY = "provincialSummary" -export const RUN_PARAMETERS_CACHE_KEY = "runParameters" -export const FIRE_CENTERS_CACHE_EXPIRATION = 12 +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) { @@ -60,35 +74,3 @@ export const readFromFilesystem = async ( return null; } }; - -export const deleteStaleCache = async ( - filesystem: FilesystemPlugin, - date: DateTime -) => { - const readdirResult = await filesystem.readdir({ - path: "/", - directory: Directory.Data, - }); - - for (const file of readdirResult.files) { - if (file.name.startsWith(CACHE_KEY)) { - try { - const result = await filesystem.readFile({ - path: file.name, - directory: Directory.Data, - encoding: Encoding.UTF8, - }); - const parsedData = JSON.parse(result.data as string); - const parsedTimeStamp = DateTime.fromISO(parsedData.timeStamp); - if (parsedTimeStamp < date) { - await filesystem.deleteFile({ - path: file.uri, - directory: Directory.Data, - }); - } - } catch { - // Ignore files if they can't be read or parsed - } - } - } -}; 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..10377da58e 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 + RunParamsAlias = aliased(RunParameters, subquery) + + # Final query: only rows with row_num == 1 + stmt = select(RunParamsAlias).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, From 9df8c04c884a5ab1eaf922162a1c1dea33e92917 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 25 Aug 2025 11:28:06 -0700 Subject: [PATCH 03/40] Hook and slice clean up --- mobile/asa-go/src/components/map/ASAGoMap.tsx | 2 +- .../profile/FireZoneUnitSummary.tsx | 2 +- .../src/components/report/AdvisoryText.tsx | 2 +- mobile/asa-go/src/hooks/dataHooks.ts | 94 ++++++++++++++++++ .../asa-go/src/hooks/useFireCentreDetails.ts | 2 +- .../src/hooks/useFireShapeAreasForDate.ts | 29 ------ .../src/hooks/useProvincialSummaryForDate.ts | 25 ----- mobile/asa-go/src/hooks/useTpiStatsForDate.ts | 27 ------ .../src/slices/fireCentreHFIFuelStatsSlice.ts | 72 -------------- .../src/slices/fireCentreTPIStatsSlice.ts | 75 --------------- .../asa-go/src/slices/fireZoneAreasSlice.ts | 83 ---------------- .../src/slices/fireZoneElevationInfoSlice.ts | 80 ---------------- .../src/slices/provincialSummarySlice.ts | 96 ------------------- 13 files changed, 98 insertions(+), 491 deletions(-) create mode 100644 mobile/asa-go/src/hooks/dataHooks.ts delete mode 100644 mobile/asa-go/src/hooks/useFireShapeAreasForDate.ts delete mode 100644 mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts delete mode 100644 mobile/asa-go/src/hooks/useTpiStatsForDate.ts delete mode 100644 mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts delete mode 100644 mobile/asa-go/src/slices/fireCentreTPIStatsSlice.ts delete mode 100644 mobile/asa-go/src/slices/fireZoneAreasSlice.ts delete mode 100644 mobile/asa-go/src/slices/fireZoneElevationInfoSlice.ts delete mode 100644 mobile/asa-go/src/slices/provincialSummarySlice.ts diff --git a/mobile/asa-go/src/components/map/ASAGoMap.tsx b/mobile/asa-go/src/components/map/ASAGoMap.tsx index f6c8984db3..6e32f3ff5e 100644 --- a/mobile/asa-go/src/components/map/ASAGoMap.tsx +++ b/mobile/asa-go/src/components/map/ASAGoMap.tsx @@ -21,7 +21,7 @@ import { fireShapeStyler, } from "@/featureStylers"; import { fireZoneExtentsMap } from "@/fireZoneUnitExtents"; -import { useFireShapeAreasForDate } from "@/hooks/useFireShapeAreasForDate"; +import { useFireShapeAreasForDate } from "@/hooks/dataHooks"; import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { createBasemapLayer, diff --git a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx index f374f5584e..65474e057c 100644 --- a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx +++ b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx @@ -1,7 +1,7 @@ import ElevationStatus from "@/components/profile/ElevationStatus"; import FuelSummary from "@/components/profile/FuelSummary"; import { useFilteredHFIStatsForDate } from "@/hooks/dataHooks"; -import { useTPIStatsForDate } from "@/hooks/useTpiStatsForDate"; +import { useTPIStatsForDate } from "@/hooks/dataHooks"; import { hasRequiredFields } from "@/utils/profileUtils"; import { Box, Grid2 as Grid, Typography } from "@mui/material"; import { useTheme } from "@mui/material/styles"; diff --git a/mobile/asa-go/src/components/report/AdvisoryText.tsx b/mobile/asa-go/src/components/report/AdvisoryText.tsx index d51a30a0d3..7ef2a19485 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -6,7 +6,7 @@ import { } from "@/api/fbaAPI"; import DefaultText from "@/components/report/DefaultText"; import { useFilteredHFIStatsForDate } from "@/hooks/dataHooks"; -import { useProvincialSummaryForDate } from "@/hooks/useProvincialSummaryForDate"; +import { useProvincialSummaryForDate } from "@/hooks/dataHooks"; import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { getTopFuelsByArea, diff --git a/mobile/asa-go/src/hooks/dataHooks.ts b/mobile/asa-go/src/hooks/dataHooks.ts new file mode 100644 index 0000000000..3f3465aa27 --- /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.ts b/mobile/asa-go/src/hooks/useFireCentreDetails.ts index 3e08a7f289..1eea6815c4 100644 --- a/mobile/asa-go/src/hooks/useFireCentreDetails.ts +++ b/mobile/asa-go/src/hooks/useFireCentreDetails.ts @@ -1,4 +1,4 @@ -import { useProvincialSummaryForDate } from "@/hooks/useProvincialSummaryForDate"; +import { useProvincialSummaryForDate } from "@/hooks/dataHooks"; import { FireCenter, FireShapeAreaDetail } from "api/fbaAPI"; import { groupBy } from "lodash"; import { DateTime } from "luxon"; diff --git a/mobile/asa-go/src/hooks/useFireShapeAreasForDate.ts b/mobile/asa-go/src/hooks/useFireShapeAreasForDate.ts deleted file mode 100644 index 1db55a8cb4..0000000000 --- a/mobile/asa-go/src/hooks/useFireShapeAreasForDate.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { FireShapeArea } from "@/api/fbaAPI"; -import { selectFireShapeAreas } from "@/store"; -import { isNil } from "lodash"; -import { DateTime } from "luxon"; -import { useMemo } from "react"; -import { useSelector } from "react-redux"; - -/** - * 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]); -}; diff --git a/mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts b/mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts deleted file mode 100644 index 73d477e415..0000000000 --- a/mobile/asa-go/src/hooks/useProvincialSummaryForDate.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FireShapeAreaDetail } from "@/api/fbaAPI"; -import { selectProvincialSummaries } from "@/store"; -import { Dictionary, groupBy, isNil } from "lodash"; -import { DateTime } from "luxon"; -import { useMemo } from "react"; -import { useSelector } from "react-redux"; - -/** - * 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]); -}; \ No newline at end of file diff --git a/mobile/asa-go/src/hooks/useTpiStatsForDate.ts b/mobile/asa-go/src/hooks/useTpiStatsForDate.ts deleted file mode 100644 index 56126227c2..0000000000 --- a/mobile/asa-go/src/hooks/useTpiStatsForDate.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FireZoneTPIStats } from "@/api/fbaAPI"; -import { selectTPIStats } from "@/store"; -import { isNil } from "lodash"; -import { DateTime } from "luxon"; -import { useMemo } from "react"; -import { useSelector } from "react-redux"; - -/** - * 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]); -}; diff --git a/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts b/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts deleted file mode 100644 index 573d990515..0000000000 --- a/mobile/asa-go/src/slices/fireCentreHFIFuelStatsSlice.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -import { AppThunk, RootState } from "@/store"; -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; 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; - } -); From fd004e8dc5b4c4ad02f5c25c9c6f5547ec318296 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 25 Aug 2025 14:02:33 -0700 Subject: [PATCH 04/40] Re-enable auth --- api/app/routers/fba.py | 223 +++++++++++++++++++++++--- mobile/asa-go/src/slices/dataSlice.ts | 112 ++++++------- wps_shared/wps_shared/schemas/fba.py | 24 ++- 3 files changed, 280 insertions(+), 79 deletions(-) diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index a1f305608d..ac63a8be31 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -17,19 +17,23 @@ 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 @@ -42,10 +46,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 @@ -242,7 +250,8 @@ async def get_hfi_fuels_data_for_fire_centre( @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 +265,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 +297,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 +331,195 @@ 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( + "/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) + + +#### ASA Go Specific Routes #### + + +@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( + "/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 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_all_zone_source_ids(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 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/mobile/asa-go/src/slices/dataSlice.ts b/mobile/asa-go/src/slices/dataSlice.ts index d6e5d707b9..c121545a28 100644 --- a/mobile/asa-go/src/slices/dataSlice.ts +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -242,6 +242,20 @@ const runParametersMatch = ( ); }; +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; +}; + const fetchFireShapeAreas = async ( todayKey: string, tomorrowKey: string, @@ -262,6 +276,20 @@ const fetchFireShapeAreas = async ( return fireShapeAreas as CacheableData; }; +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; +}; + const fetchHFIStats = async ( todayKey: string, tomorrowKey: string, @@ -283,6 +311,20 @@ const fetchHFIStats = async ( return hfiStats as CacheableData; }; +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; +}; + const fetchTpiStats = async ( todayKey: string, tomorrowKey: string, @@ -304,6 +346,20 @@ const fetchTpiStats = async ( return tpiStats as CacheableData; }; +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; +}; + const fetchProvincialSummaries = async ( todayKey: string, tomorrowKey: string, @@ -350,62 +406,6 @@ const shapeDataForCaching = ( }; }; -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; -}; - -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; -}; - -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; -}; - -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; -}; - const dataAreEqual = ( a: CacheableData | null, b: CacheableData | null 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] From 72d577fa6687c946975baa5eb36d746823a140aa Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 26 Aug 2025 15:31:33 -0700 Subject: [PATCH 05/40] Fix unit tests due to new hooks --- mobile/asa-go/src/components/map/ASAGoMap.tsx | 9 +- .../src/components/map/asaGoMap.test.tsx | 44 +- .../profile/FireZoneUnitSummary.test.tsx | 21 +- .../components/report/advisoryText.test.tsx | 508 ++++++++++-------- .../src/hooks/useFireCentreDetails.test.tsx | 81 +-- 5 files changed, 388 insertions(+), 275 deletions(-) diff --git a/mobile/asa-go/src/components/map/ASAGoMap.tsx b/mobile/asa-go/src/components/map/ASAGoMap.tsx index 6e32f3ff5e..629ea20494 100644 --- a/mobile/asa-go/src/components/map/ASAGoMap.tsx +++ b/mobile/asa-go/src/components/map/ASAGoMap.tsx @@ -42,7 +42,7 @@ import MyLocationIcon from "@mui/icons-material/MyLocation"; import LayersIcon from "@mui/icons-material/Layers"; import { Box } from "@mui/material"; import { FireCenter, FireShape } from "api/fbaAPI"; -import { cloneDeep, isNull, isUndefined } from "lodash"; +import { cloneDeep, isNil, isNull, isUndefined } from "lodash"; import { DateTime } from "luxon"; import { Map, MapBrowserEvent, Overlay, View } from "ol"; import { defaults as defaultControls } from "ol/control"; @@ -104,7 +104,7 @@ const ASAGoMap = ({ // hooks const fireShapeAreas = useFireShapeAreasForDate(date); const runParameter = useRunParameterForDate(date); - + // state const [map, setMap] = useState(null); const [scaleVisible, setScaleVisible] = useState(true); @@ -482,7 +482,10 @@ const ASAGoMap = ({ (async () => { let hfiLayer: VectorTileLayer | null = null; - if (!isNull(runParameter?.run_type) && !isNull(runParameter?.run_datetime)) { + if ( + !isNil(runParameter?.run_type) && + !isNil(runParameter?.run_datetime) + ) { hfiLayer = await createHFILayer( { filename: "hfi.pmtiles", diff --git a/mobile/asa-go/src/components/map/asaGoMap.test.tsx b/mobile/asa-go/src/components/map/asaGoMap.test.tsx index 0252190c95..2636e112fe 100644 --- a/mobile/asa-go/src/components/map/asaGoMap.test.tsx +++ b/mobile/asa-go/src/components/map/asaGoMap.test.tsx @@ -113,16 +113,20 @@ describe("ASAGoMap", () => { expect(locationButton).not.toBeDisabled(); }); - it("calls createHFILayer when date, runType, or runDatetime changes", async () => { + it("calls createHFILayer when runParameter changes", async () => { const runParameter = { - forDate: "2024-12-15", - runDatetime: "2024-12-15T15:00:00Z", - runType: RunType.FORECAST, + for_date: "2024-12-15", + run_datetime: "2024-12-15T15:00:00Z", + run_type: RunType.FORECAST, loading: false, error: null, }; const store = createTestStore({ - runParameter: runParameter, + runParameters: { + runParameters: { "2024-12-15": runParameter }, + loading: false, + error: null, + }, }); const { rerender } = render( @@ -144,11 +148,13 @@ describe("ASAGoMap", () => { ); store.dispatch({ - type: "runParameter/getRunParameterSuccess", + type: "runParameters/getRunParametersSuccess", payload: { - forDate: "2024-12-16", - runDateTime: "2024-12-16T23:00:00Z", - runType: RunType.FORECAST, + "2024-12-16": { + forDate: "2024-12-16", + runDateTime: "2024-12-16T23:00:00Z", + runType: RunType.FORECAST, + }, }, }); rerender( @@ -156,19 +162,20 @@ describe("ASAGoMap", () => { ); - 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 + waitFor(() => + 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( @@ -258,6 +265,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/report/advisoryText.test.tsx b/mobile/asa-go/src/components/report/advisoryText.test.tsx index 95377b2e78..b2ee4bf06f 100644 --- a/mobile/asa-go/src/components/report/advisoryText.test.tsx +++ b/mobile/asa-go/src/components/report/advisoryText.test.tsx @@ -2,30 +2,43 @@ 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"; 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 +48,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 +137,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 +292,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 +332,7 @@ describe("AdvisoryText", () => { ).toBeInTheDocument(); expect( screen.queryByTestId("advisory-message-wind-speed") - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); expect( screen.queryByTestId("advisory-message-slash") ).not.toBeInTheDocument(); @@ -302,6 +348,7 @@ describe("AdvisoryText", () => { selectedFireCenter={undefined} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -316,6 +363,7 @@ describe("AdvisoryText", () => { selectedFireCenter={undefined} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -332,6 +380,7 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={undefined} + date={TEST_FOR_DATE_LUXON} /> ); @@ -344,10 +393,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 +409,7 @@ describe("AdvisoryText", () => { selectedFireCenter={mockFireCenter} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -367,13 +422,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 +438,7 @@ describe("AdvisoryText", () => { selectedFireCenter={mockFireCenter} selectedFireZoneUnit={undefined} advisoryThreshold={advisoryThreshold} + date={TEST_FOR_DATE_LUXON} /> ); @@ -391,6 +449,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 +460,32 @@ describe("AdvisoryText", () => { advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} selectedFireZoneUnit={mockFireZoneUnit} + date={TEST_FOR_DATE_LUXON} /> ); + screen.debug(); assertInitialState(); - store.dispatch(getFireCentreHFIFuelStatsSuccess(initialHFIFuelStats)); + // act(() => store.dispatch(getDataSuccess(initialDataStateWithHFIFuelStats))); 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().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/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([]); }); From 33fb08643380522afad23ddb3b56436f3ffab6ec Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 26 Aug 2025 15:51:31 -0700 Subject: [PATCH 06/40] Allow viewing of offline data on launch --- mobile/asa-go/src/components/AuthWrapper.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/asa-go/src/components/AuthWrapper.tsx b/mobile/asa-go/src/components/AuthWrapper.tsx index 22e4fd5a66..21aea899a9 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 { Capacitor } from "@capacitor/core"; import { Box, CircularProgress, Typography, useTheme } from "@mui/material"; import { isNull } from "lodash"; @@ -16,13 +16,14 @@ const AuthWrapper = ({ children }: Props) => { const theme = useTheme(); const { isAuthenticated, authenticating, error } = useSelector(selectAuthentication); + const { networkStatus } = useSelector(selectNetworkStatus); // TODO implement for Android if (Capacitor.getPlatform() === "android") { return {children}; } - if (isAuthenticated) { + if (isAuthenticated || !networkStatus.connected) { return {children}; } From 88308daadeb91fb44fbe4c5c89984f64d02bef1d Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 27 Aug 2025 11:14:42 -0700 Subject: [PATCH 07/40] Download TDY and TMR hfi pmtiles on start Storage tests Allow showing cached data when starting app while offline --- api/app/routers/fba.py | 6 +- mobile/asa-go/src/App.tsx | 35 +++++- .../src/components/AuthWrapper.test.tsx | 65 ++++++++--- mobile/asa-go/src/utils/pmtilesCache.ts | 6 +- mobile/asa-go/src/utils/storage.test.ts | 109 ++++++++++++++++++ mobile/asa-go/src/utils/storage.ts | 19 ++- 6 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 mobile/asa-go/src/utils/storage.test.ts diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index ac63a8be31..999f50ec70 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -333,6 +333,9 @@ async def get_fire_centre_tpi_stats( ) +#### ASA Go Specific Routes #### + + @router.get( "/latest-sfms-run-parameters/{start_date}/{end_date}", response_model=LatestSFMSRunParameterRangeResponse, @@ -353,9 +356,6 @@ async def get_latest_sfms_run_datetime_for_date_range( return LatestSFMSRunParameterRangeResponse(run_parameters=latest_run_parameters) -#### ASA Go Specific Routes #### - - @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, diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index 5f0adb2d26..d4d660385b 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -15,9 +15,17 @@ import { } from "@/slices/geolocationSlice"; import { updateNetworkStatus } from "@/slices/networkStatusSlice"; import { fetchSFMSRunParameters } from "@/slices/runParametersSlice"; -import { AppDispatch, selectFireCenters, selectNetworkStatus } from "@/store"; +import { + AppDispatch, + selectFireCenters, + selectNetworkStatus, + selectRunParameters, +} from "@/store"; import { theme } from "@/theme"; import { NavPanel, PST_UTC_OFFSET } 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"; @@ -48,6 +56,7 @@ const App = () => { // selected redux state const { fireCenters } = useSelector(selectFireCenters); const { networkStatus } = useSelector(selectNetworkStatus); + const runParameters = useSelector(selectRunParameters); // hooks const runParameter = useRunParameterForDate(dateOfInterest); @@ -105,6 +114,30 @@ const App = () => { } }, [selectedFireShape, fireCenters]); + useEffect(() => { + 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); + } + }, [runParameters]); + useEffect(() => { if (!isNil(runParameter)) { dispatch(fetchAndCacheData()); diff --git a/mobile/asa-go/src/components/AuthWrapper.test.tsx b/mobile/asa-go/src/components/AuthWrapper.test.tsx index f6282c5471..dd69c40e87 100644 --- a/mobile/asa-go/src/components/AuthWrapper.test.tsx +++ b/mobile/asa-go/src/components/AuthWrapper.test.tsx @@ -66,7 +66,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, @@ -76,30 +76,55 @@ 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(); expect(screen.getByText("Login")).toBeInTheDocument(); }); - it("renders app description and title when 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", - }); - - renderWithProviders(); - - expect(screen.getByText("ASA Go")).toBeInTheDocument(); - const description = screen.getByTestId("app-description"); - expect(description).toBeInTheDocument(); + it("renders app description and title when 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(); + + expect(screen.getByText("ASA Go")).toBeInTheDocument(); + const description = screen.getByTestId("app-description"); + expect(description).toBeInTheDocument(); + }); + it("renders loading spinner when authenticating", () => { vi.spyOn(capacitor.Capacitor, "getPlatform").mockReturnValue("web"); vi.spyOn(selectors, "selectAuthentication").mockReturnValue({ @@ -110,6 +135,9 @@ describe("AuthWrapper", () => { idToken: undefined, token: "test-token", }); + vi.spyOn(selectors, "selectNetworkStatus").mockReturnValue({ + networkStatus: { connected: true, connectionType: "wifi" }, + }); renderWithProviders(); @@ -126,6 +154,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/utils/pmtilesCache.ts b/mobile/asa-go/src/utils/pmtilesCache.ts index a942c235ab..d0cc843140 100644 --- a/mobile/asa-go/src/utils/pmtilesCache.ts +++ b/mobile/asa-go/src/utils/pmtilesCache.ts @@ -167,7 +167,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( @@ -179,4 +179,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..e7f3fac2c8 --- /dev/null +++ b/mobile/asa-go/src/utils/storage.test.ts @@ -0,0 +1,109 @@ + +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 +import { Directory, Encoding } from '@capacitor/filesystem'; + +vi.mock("@capacitor/filesystem", () => ({ + Filesystem: { + readFile: vi.fn(), + writeFile: vi.fn(), + readdir: vi.fn(), + deleteFile: vi.fn() + }, + Directory: { Data: "DATA" }, + Encoding: { UTF8: "utf8" }, +})); +import { 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 index 838c6a211b..62d915090f 100644 --- a/mobile/asa-go/src/utils/storage.ts +++ b/mobile/asa-go/src/utils/storage.ts @@ -62,7 +62,7 @@ export const writeToFileSystem = async ( export const readFromFilesystem = async ( filesystem: FilesystemPlugin, key: string -) => { +): Promise | null> => { try { const result = await filesystem.readFile({ path: getPath(key), @@ -74,3 +74,20 @@ export const readFromFilesystem = async ( 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) + } +} From ad70cb891ae4f3c2c5df5f66bfdae9c95b34c697 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 27 Aug 2025 12:04:58 -0700 Subject: [PATCH 08/40] Remove dupes --- mobile/asa-go/src/components/map/ASAGoMap.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/mobile/asa-go/src/components/map/ASAGoMap.tsx b/mobile/asa-go/src/components/map/ASAGoMap.tsx index 2432f34d1d..a1d69630d7 100644 --- a/mobile/asa-go/src/components/map/ASAGoMap.tsx +++ b/mobile/asa-go/src/components/map/ASAGoMap.tsx @@ -32,15 +32,8 @@ import { HFI_LAYER_NAME, } from "@/layerDefinitions"; import { startWatchingLocation } from "@/slices/geolocationSlice"; +import { NavPanel } from "@/utils/constants"; import { AppDispatch, selectGeolocation, selectNetworkStatus } from "@/store"; -import { CENTER_OF_BC, NavPanel } from "@/utils/constants"; -import { - AppDispatch, - selectFireShapeAreas, - selectGeolocation, - selectNetworkStatus, - selectRunParameter, -} from "@/store"; import { PMTilesCache } from "@/utils/pmtilesCache"; import { PMTilesFileVectorSource } from "@/utils/pmtilesVectorSource"; import { Filesystem } from "@capacitor/filesystem"; From 9851f4680035f019e3899ca557f6cd8ac2b696a5 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 27 Aug 2025 16:08:08 -0700 Subject: [PATCH 09/40] Typing change --- mobile/asa-go/src/utils/storage.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/mobile/asa-go/src/utils/storage.ts b/mobile/asa-go/src/utils/storage.ts index 62d915090f..49d0b4b76f 100644 --- a/mobile/asa-go/src/utils/storage.ts +++ b/mobile/asa-go/src/utils/storage.ts @@ -62,7 +62,7 @@ export const writeToFileSystem = async ( export const readFromFilesystem = async ( filesystem: FilesystemPlugin, key: string -): Promise | null> => { +) => { try { const result = await filesystem.readFile({ path: getPath(key), @@ -75,19 +75,27 @@ export const readFromFilesystem = async ( } }; - -export const clearStaleHFIPMTiles = async (filesystem: FilesystemPlugin, hfiFilesToKeep: string[]) => { +export const clearStaleHFIPMTiles = async ( + filesystem: FilesystemPlugin, + hfiFilesToKeep: string[] +) => { try { const { files } = await filesystem.readdir({ - path: "", + 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}) + 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) + console.error(e); } -} +}; From 83b7498268388d6de311b611b10e5f6fdb09b098 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 27 Aug 2025 16:10:59 -0700 Subject: [PATCH 10/40] fba endpoint tests --- api/app/routers/fba.py | 38 ++++++++++----------- api/app/tests/fba/test_fba_endpoint.py | 47 +++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index 999f50ec70..93ae0481a1 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -333,6 +333,25 @@ async def get_fire_centre_tpi_stats( ) +@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 + + #### ASA Go Specific Routes #### @@ -356,25 +375,6 @@ async def get_latest_sfms_run_datetime_for_date_range( return LatestSFMSRunParameterRangeResponse(run_parameters=latest_run_parameters) -@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( "/hfi-stats/{run_type}/{run_datetime}/{for_date}", response_model=HFIStatsResponse, diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index bd74967fad..35030acf6d 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -18,7 +18,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" @@ -168,6 +175,32 @@ async def mock_get_sfms_bounds_no_data(*_, **__): return [] +async def mock_get_latest_sfms_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 {for_date_1: run_parameter_1, for_date_2: run_parameter_2} + + +async def mock_get_all_zone_source_ids(*_, **__): + return [1, 2, 3] + + +async def mock_get_tpi_fuel_areas(*_, **__): + return [{TPIClassEnum.mid_slope, 500, "20", 2, "Coastal"}] + + +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 @@ -440,6 +473,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-25/2025-08-26", + "/api/fba/tpi-stats/forecast/2025-08-25T15:01:47.340947Z/2025-08-26", ] @@ -458,6 +494,15 @@ def test_get_sfms_run_bounds_no_bounds(client: TestClient): @patch("app.routers.fba.get_centre_tpi_stats", mock_get_centre_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_latest_sfms_run_datetime_for_date_range", + mock_get_latest_sfms_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) def test_fba_endpoints_allowed_for_test_idir(client, endpoint): headers = {"Authorization": "Bearer token"} response = client.get(endpoint, headers=headers) From 59407fc783034ac88863afea57d7fb0f5235ce11 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 27 Aug 2025 16:48:23 -0700 Subject: [PATCH 11/40] Fix test --- api/app/tests/fba/test_fba_endpoint.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index 35030acf6d..c298362197 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -42,9 +42,8 @@ get_sfms_run_bounds_url = "/api/fba/sfms-run-bounds" 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 = [] 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)] @@ -80,7 +79,6 @@ ], ) - def create_mock_centre_tpi_stats( advisory_shape_id, source_identifier, valley_bottom, mid_slope, upper_slope, pixel_size_metres ): @@ -474,7 +472,7 @@ def test_get_sfms_run_bounds_no_bounds(client: TestClient): "/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-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", ] @@ -503,6 +501,7 @@ def test_get_sfms_run_bounds_no_bounds(client: TestClient): 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) From fe789410de6d0c2847bec5410a68e8e20dc5a215 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 27 Aug 2025 17:21:52 -0700 Subject: [PATCH 12/40] Mock db call --- api/app/tests/fba/test_fba_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index c298362197..a10b0e2379 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -173,7 +173,7 @@ async def mock_get_sfms_bounds_no_data(*_, **__): return [] -async def mock_get_latest_sfms_run_datetime_for_date_range(*_, **__): +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) @@ -183,7 +183,7 @@ async def mock_get_latest_sfms_run_datetime_for_date_range(*_, **__): run_parameter_2 = SFMSRunParameter( for_date=for_date_2, run_datetime=run_datetime, run_type=SFMSRunType.FORECAST ) - return {for_date_1: run_parameter_1, for_date_2: run_parameter_2} + return [run_parameter_1, run_parameter_2] async def mock_get_all_zone_source_ids(*_, **__): @@ -493,8 +493,8 @@ def test_get_sfms_run_bounds_no_bounds(client: TestClient): @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_latest_sfms_run_datetime_for_date_range", - mock_get_latest_sfms_run_datetime_for_date_range, + "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", From 54ae667a11edbf29b8eef8392e03433e7e5495bd Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 23 Sep 2025 14:45:36 -0700 Subject: [PATCH 13/40] run parameters slice tests --- .../src/slices/runParametersSlice.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 mobile/asa-go/src/slices/runParametersSlice.test.ts 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..d8ec070dd7 --- /dev/null +++ b/mobile/asa-go/src/slices/runParametersSlice.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, Mock } from "vitest"; +import reducer, { + initialState, + getRunParametersStart, + getRunParametersFailed, + getRunParametersSuccess, + fetchSFMSRunParameters, + selectRunParameters, + } from "@/slices/runParametersSlice"; +import { RunParameter, RunType } from "api/fbaAPI"; +import { DateTime } from "luxon"; +import { + createTestStore +} from "@/testUtils"; + +// 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 { getMostRecentRunParameters } from "api/fbaAPI"; +import { writeToFileSystem, readFromFilesystem } from "@/utils/storage"; +import { RootState } from "@/store"; + +const today = DateTime.now().toISODate(); +const tomorrow = DateTime.now().plus({ days: 1 }).toISODate(); + +const mockRunParameters: { [key: string]: RunParameter } = { + [today]: { + for_date: today, + run_datetime: "2025-08-27T08:00:00Z", + run_type: RunType.FORECAST, + }, + [tomorrow]: { + for_date: tomorrow, + 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) + }); + + 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); + }); +}); From b391bec0a2554280acd7236acee9f3e5df4ff679 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 17 Nov 2025 12:41:21 -0800 Subject: [PATCH 14/40] Cahcing tests --- mobile/asa-go/src/slices/dataSlice.test.ts | 444 ++++++++++++++++++ mobile/asa-go/src/slices/dataSlice.ts | 207 +------- mobile/asa-go/src/slices/dataSliceUtils.ts | 186 ++++++++ .../src/slices/runParametersSlice.test.ts | 13 + .../asa-go/src/slices/runParametersSlice.ts | 1 + 5 files changed, 654 insertions(+), 197 deletions(-) create mode 100644 mobile/asa-go/src/slices/dataSlice.test.ts create mode 100644 mobile/asa-go/src/slices/dataSliceUtils.ts 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..d036586fa7 --- /dev/null +++ b/mobile/asa-go/src/slices/dataSlice.test.ts @@ -0,0 +1,444 @@ +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("@/slices/dataSliceUtils", async () => { + const actual = await vi.importActual("@/slices/dataSliceUtils"); + return { + ...actual, + fetchFireShapeAreas: vi.fn(), + fetchHFIStats: vi.fn(), + fetchProvincialSummaries: vi.fn(), + fetchTpiStats: vi.fn() + } +}) + +import {fetchFireShapeAreas, fetchHFIStats, fetchProvincialSummaries, fetchTpiStats} from "@/slices/dataSliceUtils" +import reducer, {initialState, getDataStart, getDataFailed, getDataSuccess, fetchAndCacheData, DataState } from "@/slices/dataSlice" +import { describe, it, expect, vi, Mock } from "vitest"; +import { + initialState as runParametersInitialState, + } from "@/slices/runParametersSlice"; +import { DateTime } from "luxon"; +import { AdvisoryCriticalHours, AdvisoryMinWindStats, FireShapeArea, FireShapeAreaDetail, FireZoneFuelStats, FireZoneHFIStatsDictionary, FireZoneTPIStats, FuelType, HfiThreshold, RunParameter, RunType } from "api/fbaAPI"; +import { readFromFilesystem, CacheableData, FIRE_SHAPE_AREAS_KEY, PROVINCIAL_SUMMARY_KEY, TPI_STATS_KEY, HFI_STATS_KEY } from "@/utils/storage"; +import { + createTestStore +} from "@/testUtils"; + +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 = { + [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((filesystem, key) => { + switch (key) { + case PROVINCIAL_SUMMARY_KEY: + return null + case FIRE_SHAPE_AREAS_KEY: + return null + case TPI_STATS_KEY: + return null + case HFI_STATS_KEY: + 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.lastUpdated).toEqual(today); + 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 index c121545a28..d98bb55a36 100644 --- a/mobile/asa-go/src/slices/dataSlice.ts +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -1,7 +1,7 @@ +import { dataAreEqual, fetchFireShapeAreas, fetchHFIStats, fetchProvincialSummaries, fetchTpiStats, runParametersMatch } from "@/slices/dataSliceUtils"; import { AppThunk } from "@/store"; import { CacheableData, - CacheableDataType, FIRE_SHAPE_AREAS_KEY, HFI_STATS_KEY, PROVINCIAL_SUMMARY_KEY, @@ -15,14 +15,9 @@ import { FireShapeArea, FireShapeAreaDetail, FireZoneHFIStatsDictionary, - FireZoneTPIStats, - getFireShapeAreas, - getHFIStats, - getProvincialSummary, - getTPIStats, - RunParameter, + FireZoneTPIStats } from "api/fbaAPI"; -import { isEqual, isNil } from "lodash"; +import { isNil } from "lodash"; import { DateTime } from "luxon"; export interface DataState { @@ -92,8 +87,8 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { const runParameters = state.runParameters.runParameters; let isCurrent = true; // A flag indicating if the cached data and state are current if (isNil(runParameters)) { - // Run parameters are required to fetch data. Should this be an error state? - return; + 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. @@ -101,7 +96,7 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { Filesystem, PROVINCIAL_SUMMARY_KEY ); - isCurrent = + isCurrent = isCurrent && !isNil(cachedProvincialSummaries?.data) && runParametersMatch( todayKey, @@ -114,7 +109,7 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { Filesystem, FIRE_SHAPE_AREAS_KEY ); - isCurrent = + isCurrent = isCurrent && !isNil(cachedFireShapeAreas?.data) && runParametersMatch( todayKey, @@ -124,7 +119,7 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { ); const cachedTPIStats = await readFromFilesystem(Filesystem, TPI_STATS_KEY); - isCurrent = + isCurrent = isCurrent && !isNil(cachedTPIStats?.data) && runParametersMatch( todayKey, @@ -134,7 +129,7 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { ); const cachedHFIStats = await readFromFilesystem(Filesystem, HFI_STATS_KEY); - isCurrent = + isCurrent = isCurrent && !isNil(cachedHFIStats?.data) && runParametersMatch( todayKey, @@ -155,6 +150,7 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { !dataAreEqual(stateTPIStats, cachedTPIStats.data) && !dataAreEqual(stateHFIStats, cachedHFIStats.data) ) { + // Update state from cached data if required dispatch( getDataSuccess({ lastUpdated: DateTime.now().toISODate(), @@ -229,186 +225,3 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { dispatch(getDataFailed("Unable to update data. Data may be stale.")); } }; - -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) - ); -}; - -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; -}; - -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; -}; - -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; -}; - -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; -}; - -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; -}; - -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; -}; - -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; -}; - -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; -}; - -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, - }, - }; -}; - -const dataAreEqual = ( - a: CacheableData | null, - b: CacheableData | null -): boolean => { - return isEqual(a, b); -}; diff --git a/mobile/asa-go/src/slices/dataSliceUtils.ts b/mobile/asa-go/src/slices/dataSliceUtils.ts new file mode 100644 index 0000000000..525a8c51ef --- /dev/null +++ b/mobile/asa-go/src/slices/dataSliceUtils.ts @@ -0,0 +1,186 @@ +import { FireShapeArea, FireShapeAreaDetail, FireZoneHFIStatsDictionary, FireZoneTPIStats, getFireShapeAreas, getHFIStats, getProvincialSummary, getTPIStats, RunParameter } from "@/api/fbaAPI"; +import { CacheableData, CacheableDataType } from "@/utils/storage"; +import { isEqual, isNil } from "lodash"; + +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); +}; \ No newline at end of file diff --git a/mobile/asa-go/src/slices/runParametersSlice.test.ts b/mobile/asa-go/src/slices/runParametersSlice.test.ts index d8ec070dd7..4c1d59e85b 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.test.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.test.ts @@ -83,8 +83,21 @@ describe("fetchSFMSRunParameters thunk", () => { }); 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)); diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index 630e521dd3..806dd1d8ed 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -102,6 +102,7 @@ export const fetchSFMSRunParameters = ); } } + dispatch(getRunParametersFailed("Unable to update runParameters from the API.")) } catch (err) { dispatch(getRunParametersFailed((err as Error).toString())); console.log(err); From a4ddd16b7d6855f28bdc42882d53d11be9b6275a Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 18 Nov 2025 11:26:47 -0800 Subject: [PATCH 15/40] Dedupe in fba router --- api/app/routers/fba.py | 234 +++++++++++++++-------------------------- 1 file changed, 86 insertions(+), 148 deletions(-) diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index 93ae0481a1..08add7d04f 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, @@ -37,7 +38,7 @@ 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, @@ -65,6 +66,84 @@ ) +async def get_all_zone_data_for_source_ids( + session: AsyncSession, + zone_source_ids: List[SFMSFuelType], + 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.""" @@ -89,7 +168,6 @@ 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 ) @@ -169,83 +247,13 @@ 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) @@ -396,83 +404,13 @@ async def get_hfi_fuels_data_for_run_parameter( ) 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_all_zone_source_ids(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 + 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 HFIStatsResponse(zone_data=all_zone_data) + return HFIStatsResponse(zone_data=all_zone_data) @router.get( From ad9710bafb89051471ccdd3bad7fbecb267d93b0 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 18 Nov 2025 11:47:44 -0800 Subject: [PATCH 16/40] Code clean up --- mobile/asa-go/src/components/AuthWrapper.test.tsx | 7 ------- .../src/components/profile/FireZoneUnitSummary.tsx | 6 ++++-- .../asa-go/src/components/report/AdvisoryText.tsx | 6 ++++-- .../src/components/report/advisoryText.test.tsx | 1 - mobile/asa-go/src/slices/dataSlice.test.ts | 13 ++----------- mobile/asa-go/src/slices/runParametersSlice.test.ts | 3 +-- mobile/asa-go/src/slices/runParametersSlice.ts | 6 +++--- mobile/asa-go/src/utils/hfiStatsUtils.ts | 2 +- mobile/asa-go/src/utils/storage.test.ts | 3 +-- .../wps_shared/db/crud/auto_spatial_advisory.py | 4 ++-- 10 files changed, 18 insertions(+), 33 deletions(-) diff --git a/mobile/asa-go/src/components/AuthWrapper.test.tsx b/mobile/asa-go/src/components/AuthWrapper.test.tsx index c1c0bb9098..961ebdc89c 100644 --- a/mobile/asa-go/src/components/AuthWrapper.test.tsx +++ b/mobile/asa-go/src/components/AuthWrapper.test.tsx @@ -109,13 +109,6 @@ describe("AuthWrapper", () => { expect(description).toBeInTheDocument(); }); - renderWithProviders(); - - expect(screen.getByText("ASA Go")).toBeInTheDocument(); - const description = screen.getByTestId("app-description"); - expect(description).toBeInTheDocument(); - }); - it("renders loading spinner when authenticating", () => { vi.spyOn(capacitor.Capacitor, "getPlatform").mockReturnValue("web"); vi.spyOn(selectors, "selectAuthentication").mockReturnValue({ diff --git a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx index 65474e057c..ff1bb9b1c6 100644 --- a/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx +++ b/mobile/asa-go/src/components/profile/FireZoneUnitSummary.tsx @@ -1,7 +1,9 @@ import ElevationStatus from "@/components/profile/ElevationStatus"; import FuelSummary from "@/components/profile/FuelSummary"; -import { useFilteredHFIStatsForDate } from "@/hooks/dataHooks"; -import { useTPIStatsForDate } from "@/hooks/dataHooks"; +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"; diff --git a/mobile/asa-go/src/components/report/AdvisoryText.tsx b/mobile/asa-go/src/components/report/AdvisoryText.tsx index 7ef2a19485..8e8c00c4ed 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -5,8 +5,10 @@ import { FireZoneHFIStats, } from "@/api/fbaAPI"; import DefaultText from "@/components/report/DefaultText"; -import { useFilteredHFIStatsForDate } from "@/hooks/dataHooks"; -import { useProvincialSummaryForDate } from "@/hooks/dataHooks"; +import { + useFilteredHFIStatsForDate, + useProvincialSummaryForDate, +} from "@/hooks/dataHooks"; import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { getTopFuelsByArea, diff --git a/mobile/asa-go/src/components/report/advisoryText.test.tsx b/mobile/asa-go/src/components/report/advisoryText.test.tsx index b2ee4bf06f..7db09c41b7 100644 --- a/mobile/asa-go/src/components/report/advisoryText.test.tsx +++ b/mobile/asa-go/src/components/report/advisoryText.test.tsx @@ -466,7 +466,6 @@ describe("AdvisoryText", () => { ); screen.debug(); assertInitialState(); - // act(() => store.dispatch(getDataSuccess(initialDataStateWithHFIFuelStats))); await waitFor(() => expect( screen.queryByTestId("advisory-message-warning") diff --git a/mobile/asa-go/src/slices/dataSlice.test.ts b/mobile/asa-go/src/slices/dataSlice.test.ts index d036586fa7..6eb495ba0e 100644 --- a/mobile/asa-go/src/slices/dataSlice.test.ts +++ b/mobile/asa-go/src/slices/dataSlice.test.ts @@ -310,17 +310,8 @@ describe("fetchAndCacheData thunk", () => { }) } const mockCacheWithNoData = () => { - (readFromFilesystem as Mock).mockImplementation((filesystem, key) => { - switch (key) { - case PROVINCIAL_SUMMARY_KEY: - return null - case FIRE_SHAPE_AREAS_KEY: - return null - case TPI_STATS_KEY: - return null - case HFI_STATS_KEY: - return null - } + (readFromFilesystem as Mock).mockImplementation(() => { + return null }) } const mockAPIData = () => { diff --git a/mobile/asa-go/src/slices/runParametersSlice.test.ts b/mobile/asa-go/src/slices/runParametersSlice.test.ts index 4c1d59e85b..752d6181f5 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.test.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.test.ts @@ -7,7 +7,6 @@ import reducer, { fetchSFMSRunParameters, selectRunParameters, } from "@/slices/runParametersSlice"; -import { RunParameter, RunType } from "api/fbaAPI"; import { DateTime } from "luxon"; import { createTestStore @@ -28,7 +27,7 @@ vi.mock("@/utils/storage", () => ({ RUN_PARAMETERS_CACHE_KEY: "runParameters", })); -import { getMostRecentRunParameters } from "api/fbaAPI"; +import { getMostRecentRunParameters, RunParameter, RunType } from "api/fbaAPI"; import { writeToFileSystem, readFromFilesystem } from "@/utils/storage"; import { RootState } from "@/store"; diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index 806dd1d8ed..4fd682e387 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -114,11 +114,11 @@ export const fetchSFMSRunParameters = Filesystem, RUN_PARAMETERS_CACHE_KEY ); - const cachedRunParameters: { [key: string]: RunParameter } = !isNil( + const cachedRunParameters: { [key: string]: RunParameter } = isNil( cachedData ) - ? cachedData.data - : null; + ? null + : cachedData.data; if ( !isNil(cachedRunParameters) && (isNil(reduxRunParameters) || diff --git a/mobile/asa-go/src/utils/hfiStatsUtils.ts b/mobile/asa-go/src/utils/hfiStatsUtils.ts index dd93abc2f7..137bf7c264 100644 --- a/mobile/asa-go/src/utils/hfiStatsUtils.ts +++ b/mobile/asa-go/src/utils/hfiStatsUtils.ts @@ -17,7 +17,7 @@ export const filterHFIFuelStatsByArea = ( ) => { const filteredFireZoneStats: FireZoneHFIStatsDictionary = {}; for (const [key, value] of Object.entries(fireCentreHFIFuelStats)) { - filteredFireZoneStats[parseInt(key)] = { + filteredFireZoneStats[Number.parseInt(key)] = { min_wind_stats: value.min_wind_stats, fuel_area_stats: filterHFIStatsByArea(value.fuel_area_stats), }; diff --git a/mobile/asa-go/src/utils/storage.test.ts b/mobile/asa-go/src/utils/storage.test.ts index e7f3fac2c8..f1e19defa8 100644 --- a/mobile/asa-go/src/utils/storage.test.ts +++ b/mobile/asa-go/src/utils/storage.test.ts @@ -9,7 +9,6 @@ import { CacheableDataType, CacheableData, } from '@/utils/storage'; // adjust path as needed -import { Directory, Encoding } from '@capacitor/filesystem'; vi.mock("@capacitor/filesystem", () => ({ Filesystem: { @@ -21,7 +20,7 @@ vi.mock("@capacitor/filesystem", () => ({ Directory: { Data: "DATA" }, Encoding: { UTF8: "utf8" }, })); -import { Filesystem} from '@capacitor/filesystem'; +import { Directory, Encoding, Filesystem} from '@capacitor/filesystem'; import { FireShapeArea, RunType } from '@/api/fbaAPI'; const mockData: FireShapeArea[] = [] 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 10377da58e..4b61af1d51 100644 --- a/wps_shared/wps_shared/db/crud/auto_spatial_advisory.py +++ b/wps_shared/wps_shared/db/crud/auto_spatial_advisory.py @@ -444,10 +444,10 @@ async def get_most_recent_run_datetime_for_date_range( ) # Alias the subquery for querying - RunParamsAlias = aliased(RunParameters, subquery) + run_params_alias = aliased(RunParameters, subquery) # Final query: only rows with row_num == 1 - stmt = select(RunParamsAlias).where(subquery.c.row_num == 1) + stmt = select(run_params_alias).where(subquery.c.row_num == 1) result = await session.execute(stmt) return result.scalars() From 15c70390e43da7e0ebf09b68b738cb800631a987 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 18 Nov 2025 13:08:47 -0800 Subject: [PATCH 17/40] test fix --- mobile/asa-go/src/components/map/asaGoMap.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/asa-go/src/components/map/asaGoMap.test.tsx b/mobile/asa-go/src/components/map/asaGoMap.test.tsx index 9656ba6486..69cffbda5b 100644 --- a/mobile/asa-go/src/components/map/asaGoMap.test.tsx +++ b/mobile/asa-go/src/components/map/asaGoMap.test.tsx @@ -148,7 +148,7 @@ describe("ASAGoMap", () => { true ); - store.dispatch({ + await store.dispatch({ type: "runParameters/getRunParametersSuccess", payload: { "2024-12-16": { From 9b08f356c0e93f05e91a1b3996ddca62fe399066 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 19 Nov 2025 15:45:50 -0800 Subject: [PATCH 18/40] Fix test --- .../src/components/map/asaGoMap.test.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/mobile/asa-go/src/components/map/asaGoMap.test.tsx b/mobile/asa-go/src/components/map/asaGoMap.test.tsx index 69cffbda5b..d567ff06ac 100644 --- a/mobile/asa-go/src/components/map/asaGoMap.test.tsx +++ b/mobile/asa-go/src/components/map/asaGoMap.test.tsx @@ -117,7 +117,7 @@ describe("ASAGoMap", () => { it("calls createHFILayer when runParameter changes", async () => { const runParameter = { for_date: "2024-12-15", - run_datetime: "2024-12-15T15:00:00Z", + run_datetime: "2024-12-14T15:00:00Z", run_type: RunType.FORECAST, loading: false, error: null, @@ -130,7 +130,7 @@ describe("ASAGoMap", () => { }, }); - const { rerender } = render( + render( @@ -143,7 +143,7 @@ describe("ASAGoMap", () => { filename: "hfi.pmtiles", for_date: DateTime.fromISO("2024-12-15"), run_type: RunType.FORECAST, - run_date: DateTime.fromISO("2024-12-15T15:00:00Z"), + run_date: DateTime.fromISO("2024-12-14T15:00:00Z"), }), true ); @@ -151,25 +151,21 @@ describe("ASAGoMap", () => { await store.dispatch({ type: "runParameters/getRunParametersSuccess", payload: { - "2024-12-16": { - forDate: "2024-12-16", - runDateTime: "2024-12-16T23:00:00Z", + "2024-12-15": { + forDate: "2024-12-15", + runDateTime: "2024-12-15T23:00:00Z", runType: RunType.FORECAST, }, }, }); - rerender( - - - - ); + waitFor(() => expect(createHFILayer).toHaveBeenCalledWith( expect.objectContaining({ filename: "hfi.pmtiles", - for_date: DateTime.fromISO("2024-12-16"), + for_date: DateTime.fromISO("2024-12-15"), run_type: RunType.FORECAST, - run_date: DateTime.fromISO("2024-12-16T23:00:00Z"), + run_date: DateTime.fromISO("2024-12-15T23:00:00Z"), }), true ) From f3008c39a17e3e59bdc5c31f3f3d0216379e0a2d Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 19 Nov 2025 16:00:54 -0800 Subject: [PATCH 19/40] Remove test --- .../src/components/map/asaGoMap.test.tsx | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/mobile/asa-go/src/components/map/asaGoMap.test.tsx b/mobile/asa-go/src/components/map/asaGoMap.test.tsx index d567ff06ac..64ff8a2f1a 100644 --- a/mobile/asa-go/src/components/map/asaGoMap.test.tsx +++ b/mobile/asa-go/src/components/map/asaGoMap.test.tsx @@ -114,63 +114,6 @@ describe("ASAGoMap", () => { expect(locationButton).not.toBeDisabled(); }); - it("calls createHFILayer when runParameter changes", async () => { - const runParameter = { - for_date: "2024-12-15", - run_datetime: "2024-12-14T15:00:00Z", - run_type: RunType.FORECAST, - loading: false, - error: null, - }; - const store = createTestStore({ - runParameters: { - runParameters: { "2024-12-15": runParameter }, - loading: false, - error: null, - }, - }); - - 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-14T15:00:00Z"), - }), - true - ); - - await store.dispatch({ - type: "runParameters/getRunParametersSuccess", - payload: { - "2024-12-15": { - forDate: "2024-12-15", - runDateTime: "2024-12-15T23:00:00Z", - runType: RunType.FORECAST, - }, - }, - }); - - waitFor(() => - 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-15T23:00:00Z"), - }), - true - ) - ); - }); it("renders the layer switcher button and legend on click", async () => { const store = createTestStore(); const { getByTestId } = render( From 7d111f3106d21b1f8d9ba25f6250f2f9a031b65b Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 20 Nov 2025 11:10:01 -0800 Subject: [PATCH 20/40] fire centre slice tests --- .../src/components/map/asaGoMap.test.tsx | 3 +- .../src/slices/fireCentersSlice.test.ts | 180 ++++++++++++++++++ mobile/asa-go/src/slices/fireCentersSlice.ts | 5 +- 3 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 mobile/asa-go/src/slices/fireCentersSlice.test.ts diff --git a/mobile/asa-go/src/components/map/asaGoMap.test.tsx b/mobile/asa-go/src/components/map/asaGoMap.test.tsx index 64ff8a2f1a..5cc0215492 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 { baseLayerMock, } from "@/testUtils"; import { geolocationInitialState } from "@/slices/geolocationSlice"; -import { RunType } from "@/api/fbaAPI"; import * as mapView from "@/components/map/mapView"; vi.mock("@capacitor/filesystem", () => ({ @@ -46,7 +45,7 @@ vi.mock("@/layerDefinitions", async () => { }; }); -import { createHFILayer, HFI_LAYER_NAME } from "@/layerDefinitions"; +import { HFI_LAYER_NAME } from "@/layerDefinitions"; describe("ASAGoMap", () => { beforeAll(() => { 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..e4f99b53bb --- /dev/null +++ b/mobile/asa-go/src/slices/fireCentersSlice.test.ts @@ -0,0 +1,180 @@ +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().toISODate(); + const yesterday = DateTime.now().plus({ days: -1 }).toISODate(); + 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 error 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.error).toMatch(/Unable to refresh fire center data/); + }); + + it("should dispatch success when cache is fresh and app is offline", async () => { + mockCacheWithData(false); + 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([mockFireCenterB]); + expect(getFBAFireCenters).not.toBeCalled(); + }); +}); diff --git a/mobile/asa-go/src/slices/fireCentersSlice.ts b/mobile/asa-go/src/slices/fireCentersSlice.ts index 0320005631..7ef216b912 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.ts @@ -1,5 +1,3 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - import { AppThunk } from "@/store"; import { FIRE_CENTERS_CACHE_EXPIRATION, @@ -8,6 +6,7 @@ import { 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"; @@ -18,7 +17,7 @@ export interface FireCentresState { fireCenters: FireCenter[]; } -const initialState: FireCentresState = { +export const initialState: FireCentresState = { loading: false, error: null, fireCenters: [], From 18011a0da90a2397bb4d5b127b73f46743000b33 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 20 Nov 2025 12:04:56 -0800 Subject: [PATCH 21/40] data slice utils tests --- .../asa-go/src/slices/dataSliceUtils.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 mobile/asa-go/src/slices/dataSliceUtils.test.ts diff --git a/mobile/asa-go/src/slices/dataSliceUtils.test.ts b/mobile/asa-go/src/slices/dataSliceUtils.test.ts new file mode 100644 index 0000000000..d0a06ca429 --- /dev/null +++ b/mobile/asa-go/src/slices/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 "@/slices/dataSliceUtils"; // adjust path +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); + }); +}); From 3d10af2a186c4c9689f90dc66aa583691c7f14a3 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 20 Nov 2025 13:22:05 -0800 Subject: [PATCH 22/40] fix --- mobile/asa-go/src/slices/fireCentersSlice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/asa-go/src/slices/fireCentersSlice.test.ts b/mobile/asa-go/src/slices/fireCentersSlice.test.ts index e4f99b53bb..6d63da33ce 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.test.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.test.ts @@ -173,8 +173,8 @@ describe("fetchFireCenters thunk", () => { }); await store.dispatch(fetchFireCenters()); const state = store.getState().fireCenters; + expect(getFBAFireCenters).not.toBeCalled(); expect(state.loading).toBe(false); expect(state.fireCenters).toEqual([mockFireCenterB]); - expect(getFBAFireCenters).not.toBeCalled(); }); }); From 2f7b7e679cee83667163008510e8bc5754ee4aa8 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 20 Nov 2025 13:44:23 -0800 Subject: [PATCH 23/40] More fba endpoint tests --- api/app/tests/fba/test_fba_endpoint.py | 109 ++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 4 deletions(-) diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index a10b0e2379..20d6638fab 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -40,10 +40,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 = [] +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)] @@ -95,6 +96,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"] @@ -130,8 +199,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(*_, **__): @@ -154,6 +223,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] @@ -191,7 +264,7 @@ async def mock_get_all_zone_source_ids(*_, **__): async def mock_get_tpi_fuel_areas(*_, **__): - return [{TPIClassEnum.mid_slope, 500, "20", 2, "Coastal"}] + 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(*_, **__): @@ -236,6 +309,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): @@ -437,6 +511,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 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) @@ -490,6 +590,7 @@ 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( From 53d2e8b9ed9b3ed3417ed749158fa092c0588af0 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 20 Nov 2025 13:47:16 -0800 Subject: [PATCH 24/40] Remove useless test --- mobile/asa-go/src/slices/fireCentersSlice.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/mobile/asa-go/src/slices/fireCentersSlice.test.ts b/mobile/asa-go/src/slices/fireCentersSlice.test.ts index 6d63da33ce..1de5bbebe6 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.test.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.test.ts @@ -162,19 +162,4 @@ describe("fetchFireCenters thunk", () => { expect(state.loading).toBe(false); expect(state.error).toMatch(/Unable to refresh fire center data/); }); - - it("should dispatch success when cache is fresh and app is offline", async () => { - mockCacheWithData(false); - const store = createTestStore({ - fireCenters: { ...initialState }, - networkStatus: { - networkStatus: { connected: false, connectionType: "none" }, - }, - }); - await store.dispatch(fetchFireCenters()); - const state = store.getState().fireCenters; - expect(getFBAFireCenters).not.toBeCalled(); - expect(state.loading).toBe(false); - expect(state.fireCenters).toEqual([mockFireCenterB]); - }); }); From ab0986c331ce2232c0d755dc59d7fa6487128e6c Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 20 Nov 2025 14:30:31 -0800 Subject: [PATCH 25/40] Fix logic error --- mobile/asa-go/src/slices/fireCentersSlice.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/asa-go/src/slices/fireCentersSlice.test.ts b/mobile/asa-go/src/slices/fireCentersSlice.test.ts index 1de5bbebe6..c35999c3c8 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.test.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.test.ts @@ -53,8 +53,8 @@ describe("fetchFireCenters thunk", () => { // Reset all mocks before each test vi.clearAllMocks(); }); - const today = DateTime.now().toISODate(); - const yesterday = DateTime.now().plus({ days: -1 }).toISODate(); + const today = DateTime.now().toISO(); + const yesterday = DateTime.now().plus({ days: -1 }).toISO(); const mockFireCenterA: FireCenter = { id: 1, name: "test", From d33cf9d53791b273db6b543253b914359c711f93 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 20 Nov 2025 14:38:09 -0800 Subject: [PATCH 26/40] fixes --- api/app/tests/fba/test_fba_endpoint.py | 9 ++++++++- mobile/asa-go/src/slices/runParametersSlice.ts | 9 ++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index 20d6638fab..0d334e9c2b 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 @@ -200,6 +201,7 @@ async def mock_get_auth_header(*_, **__): async def mock_get_tpi_stats_empty(*_, **__): + await asyncio.sleep(0) return mock_tpi_stats_empty @@ -243,10 +245,12 @@ async def mock_get_sfms_bounds(*_, **__): async def mock_get_sfms_bounds_no_data(*_, **__): + await asyncio.sleep(0) return [] async def mock_get_most_recent_run_datetime_for_date_range(*_, **__): + await asyncio.sleep(0) for_date_1 = date(2025, 8, 25) for_date_2 = date(2025, 8, 26) run_datetime = datetime(2025, 8, 25) @@ -260,14 +264,17 @@ async def mock_get_most_recent_run_datetime_for_date_range(*_, **__): async def mock_get_all_zone_source_ids(*_, **__): + await asyncio.sleep(0) return [1, 2, 3] async def mock_get_tpi_fuel_areas(*_, **__): + await asyncio.sleep(0) 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(*_, **__): + await asyncio.sleep(0) mock_fire_zone_hfi_stats = FireZoneHFIStats(min_wind_stats=[], fuel_area_stats=[]) return HFIStatsResponse(zone_data={1: mock_fire_zone_hfi_stats}) @@ -526,7 +533,7 @@ def test_get_tpi_stats_authorized(client: TestClient): 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 json_response["firezone_tpi_stats"][0]["mid_slope_tpi"] == 2.0 + 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 diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index 4fd682e387..c0eda70ac3 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -100,12 +100,15 @@ export const fetchSFMSRunParameters = 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 @@ -135,10 +138,10 @@ export const fetchSFMSRunParameters = runParameters: cachedRunParameters, }) ); - } else { - // We're offline and there are no cached run parameters for today - dispatch(getRunParametersFailed("No run parameters available.")); + return } + // We're offline and there are no cached run parameters for today + dispatch(getRunParametersFailed("No run parameters available.")); } }; From 21e067450e080a12bfad8d685a61d2d4f30b97b7 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 24 Nov 2025 11:07:56 -0800 Subject: [PATCH 27/40] dataHook tests --- mobile/asa-go/src/hooks/dataHooks.test.tsx | 199 +++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 mobile/asa-go/src/hooks/dataHooks.test.tsx 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([]); + }); +}); From f3bbccad177c52af79c997d999c2f430db0bfcd8 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 24 Nov 2025 11:13:48 -0800 Subject: [PATCH 28/40] Null checks --- mobile/asa-go/src/hooks/dataHooks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/asa-go/src/hooks/dataHooks.ts b/mobile/asa-go/src/hooks/dataHooks.ts index 3f3465aa27..dd30c46946 100644 --- a/mobile/asa-go/src/hooks/dataHooks.ts +++ b/mobile/asa-go/src/hooks/dataHooks.ts @@ -16,7 +16,7 @@ export const useFilteredHFIStatsForDate = ( ): FireZoneHFIStatsDictionary => { const hfiStats = useSelector(selectHFIStats); return useMemo(() => { - const forDateString = forDate.toISODate(); + const forDateString = forDate?.toISODate(); if ( isNil(forDate) || isNil(forDateString) || @@ -40,7 +40,7 @@ export const useFireShapeAreasForDate = ( ): FireShapeArea[] => { const fireShapeAreas = useSelector(selectFireShapeAreas); return useMemo(() => { - const forDateString = forDate.toISODate(); + const forDateString = forDate?.toISODate(); if ( isNil(forDate) || isNil(forDateString) || @@ -63,7 +63,7 @@ export const useProvincialSummaryForDate = ( ): Dictionary | undefined => { const provincialSummaries = useSelector(selectProvincialSummaries); return useMemo(() => { - const forDateString = forDate.toISODate() + const forDateString = forDate?.toISODate() if (isNil(forDate) || isNil(forDateString) || isNil(provincialSummaries?.[forDateString]?.data)) { return undefined; } @@ -80,7 +80,7 @@ export const useProvincialSummaryForDate = ( export const useTPIStatsForDate = (forDate: DateTime): FireZoneTPIStats[] => { const tpiStats = useSelector(selectTPIStats); return useMemo(() => { - const forDateString = forDate.toISODate(); + const forDateString = forDate?.toISODate(); if ( isNil(forDate) || isNil(forDateString) || From 86e065aad436e378d8949f94e51019fd9db588b7 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 24 Nov 2025 14:25:00 -0800 Subject: [PATCH 29/40] Simplify app startup date --- mobile/asa-go/src/App.tsx | 7 +++--- .../src/components/TodayTomorrowSwitch.tsx | 7 +++--- .../src/components/report/AdvisoryText.tsx | 3 ++- mobile/asa-go/src/slices/dataSlice.ts | 12 ++++------ mobile/asa-go/src/slices/dataSliceUtils.ts | 24 +++++++++++++++++-- mobile/asa-go/src/slices/fireCentersSlice.ts | 6 ++--- .../src/slices/runParametersSlice.test.ts | 14 +++++------ .../asa-go/src/slices/runParametersSlice.ts | 9 ++++--- 8 files changed, 49 insertions(+), 33 deletions(-) diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index d4d660385b..a68329c2c4 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -8,6 +8,7 @@ import TabPanel from "@/components/TabPanel"; import { useAppIsActive } from "@/hooks/useAppIsActive"; import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { fetchAndCacheData } from "@/slices/dataSlice"; +import { today } from "@/slices/dataSliceUtils"; import { fetchFireCenters } from "@/slices/fireCentersSlice"; import { startWatchingLocation, @@ -22,7 +23,7 @@ import { 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"; @@ -49,9 +50,7 @@ const App = () => { const [selectedFireShape, setSelectedFireShape] = useState< FireShape | undefined >(undefined); - const [dateOfInterest, setDateOfInterest] = useState( - DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`) - ); + const [dateOfInterest, setDateOfInterest] = useState(today); // selected redux state const { fireCenters } = useSelector(selectFireCenters); diff --git a/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx b/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx index bb79a86f88..921e70167a 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 "@/slices/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/report/AdvisoryText.tsx b/mobile/asa-go/src/components/report/AdvisoryText.tsx index 8e8c00c4ed..0d7ec42399 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -10,6 +10,7 @@ import { useProvincialSummaryForDate, } from "@/hooks/dataHooks"; import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; +import { today } from "@/slices/dataSliceUtils"; import { getTopFuelsByArea, getTopFuelsByProportion, @@ -231,7 +232,7 @@ const AdvisoryText = ({ const renderAdvisoryText = () => { const zoneTitle = `${selectedFireZoneUnit?.mof_fire_zone_name}:\n\n`; - const forToday = runParameter?.for_date === DateTime.now().toISODate(); + const forToday = runParameter?.for_date === today.toISODate(); const displayForDate = forToday ? "today" : DateTime.fromISO(runParameter!.for_date).toLocaleString({ diff --git a/mobile/asa-go/src/slices/dataSlice.ts b/mobile/asa-go/src/slices/dataSlice.ts index d98bb55a36..6e3b9baaf7 100644 --- a/mobile/asa-go/src/slices/dataSlice.ts +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -1,4 +1,4 @@ -import { dataAreEqual, fetchFireShapeAreas, fetchHFIStats, fetchProvincialSummaries, fetchTpiStats, runParametersMatch } from "@/slices/dataSliceUtils"; +import { dataAreEqual, fetchFireShapeAreas, fetchHFIStats, fetchProvincialSummaries, fetchTpiStats, getTodayKey, getTomorrowKey, runParametersMatch, today } from "@/slices/dataSliceUtils"; import { AppThunk } from "@/store"; import { CacheableData, @@ -79,10 +79,8 @@ export const { getDataStart, getDataFailed, getDataSuccess } = export default dataSlice.reducer; export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { - const today = DateTime.now(); - const tomorrow = today.plus({ days: 1 }); - const todayKey = today.toISODate(); - const tomorrowKey = tomorrow.toISODate(); + 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 @@ -153,7 +151,7 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { // Update state from cached data if required dispatch( getDataSuccess({ - lastUpdated: DateTime.now().toISODate(), + lastUpdated: DateTime.now().toISO(), fireShapeAreas: cachedFireShapeAreas.data, provincialSummaries: cachedProvincialSummaries.data, tpiStats: cachedTPIStats.data, @@ -210,7 +208,7 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { // Update state dispatch( getDataSuccess({ - lastUpdated: DateTime.now().toISODate(), + lastUpdated: DateTime.now().toISO(), fireShapeAreas: fireShapeAreas, provincialSummaries, tpiStats, diff --git a/mobile/asa-go/src/slices/dataSliceUtils.ts b/mobile/asa-go/src/slices/dataSliceUtils.ts index 525a8c51ef..7da916d1e4 100644 --- a/mobile/asa-go/src/slices/dataSliceUtils.ts +++ b/mobile/asa-go/src/slices/dataSliceUtils.ts @@ -1,6 +1,26 @@ -import { FireShapeArea, FireShapeAreaDetail, FireZoneHFIStatsDictionary, FireZoneTPIStats, getFireShapeAreas, getHFIStats, getProvincialSummary, getTPIStats, RunParameter } from "@/api/fbaAPI"; +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, @@ -183,4 +203,4 @@ export const dataAreEqual = ( b: CacheableData | null ): boolean => { return isEqual(a, b); -}; \ No newline at end of file +}; diff --git a/mobile/asa-go/src/slices/fireCentersSlice.ts b/mobile/asa-go/src/slices/fireCentersSlice.ts index 7ef216b912..17f79f5469 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.ts @@ -1,3 +1,4 @@ +import { today } from "@/slices/dataSliceUtils"; import { AppThunk } from "@/store"; import { FIRE_CENTERS_CACHE_EXPIRATION, @@ -59,7 +60,6 @@ export const { export default fireCentersSlice.reducer; export const fetchFireCenters = (): AppThunk => async (dispatch, getState) => { - const now = DateTime.now(); // Check for cached fire centers data. If the data is not stale save it in redux state. const cachedFireCenters = await readFromFilesystem( Filesystem, @@ -67,7 +67,7 @@ export const fetchFireCenters = (): AppThunk => async (dispatch, getState) => { ); if (!isNull(cachedFireCenters)) { const lastUpdated = DateTime.fromISO(cachedFireCenters.lastUpdated); - if (lastUpdated.plus({ hours: FIRE_CENTERS_CACHE_EXPIRATION }) > now) { + if (lastUpdated.plus({ hours: FIRE_CENTERS_CACHE_EXPIRATION }) > today) { dispatch(getFireCentersSuccess(cachedFireCenters.data)); return; } @@ -82,7 +82,7 @@ export const fetchFireCenters = (): AppThunk => async (dispatch, getState) => { Filesystem, FIRE_CENTERS_KEY, fireCenters.fire_centers, - now + today ); dispatch(getFireCentersSuccess(fireCenters.fire_centers)); } catch (err) { diff --git a/mobile/asa-go/src/slices/runParametersSlice.test.ts b/mobile/asa-go/src/slices/runParametersSlice.test.ts index 752d6181f5..34d1d8493f 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.test.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.test.ts @@ -7,7 +7,6 @@ import reducer, { fetchSFMSRunParameters, selectRunParameters, } from "@/slices/runParametersSlice"; -import { DateTime } from "luxon"; import { createTestStore } from "@/testUtils"; @@ -30,18 +29,19 @@ vi.mock("@/utils/storage", () => ({ import { getMostRecentRunParameters, RunParameter, RunType } from "api/fbaAPI"; import { writeToFileSystem, readFromFilesystem } from "@/utils/storage"; import { RootState } from "@/store"; +import { getTodayKey, getTomorrowKey } from "@/slices/dataSliceUtils"; -const today = DateTime.now().toISODate(); -const tomorrow = DateTime.now().plus({ days: 1 }).toISODate(); +const todayKey = getTodayKey() +const tomorrowKey = getTomorrowKey() const mockRunParameters: { [key: string]: RunParameter } = { - [today]: { - for_date: today, + [todayKey]: { + for_date: todayKey, run_datetime: "2025-08-27T08:00:00Z", run_type: RunType.FORECAST, }, - [tomorrow]: { - for_date: tomorrow, + [tomorrowKey]: { + for_date: tomorrowKey, run_datetime: "2025-08-28T08:00:00Z", run_type: RunType.FORECAST, }, diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index c0eda70ac3..b798bec6d3 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -1,3 +1,4 @@ +import { getTodayKey, getTomorrowKey, today } from "@/slices/dataSliceUtils"; import { AppThunk, RootState } from "@/store"; import { readFromFilesystem, @@ -8,7 +9,6 @@ import { Filesystem } from "@capacitor/filesystem"; import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { getMostRecentRunParameters, RunParameter } from "api/fbaAPI"; import { isNil } from "lodash"; -import { DateTime } from "luxon"; export interface RunParametersState { loading: boolean; @@ -60,9 +60,8 @@ export default runParameterSlice.reducer; export const fetchSFMSRunParameters = (): AppThunk => async (dispatch, getState) => { - const now = DateTime.now(); - const todayKey = now.toISODate(); - const tomorrowKey = now.plus({ days: 1 }).toISODate(); + const todayKey = getTodayKey() + const tomorrowKey = getTomorrowKey() const state = getState(); const connected = state.networkStatus.networkStatus.connected; const reduxRunParameters = state.runParameters.runParameters; @@ -82,7 +81,7 @@ export const fetchSFMSRunParameters = Filesystem, RUN_PARAMETERS_CACHE_KEY, latestRunParameters, - now + today ); if ( From 1bb76b79422818a2a61abd06ab2060e4ffa25beb Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 24 Nov 2025 14:28:22 -0800 Subject: [PATCH 30/40] Formatting --- mobile/asa-go/src/slices/dataSlice.test.ts | 374 ++++++++++-------- mobile/asa-go/src/slices/dataSlice.ts | 38 +- .../src/slices/runParametersSlice.test.ts | 84 ++-- .../asa-go/src/slices/runParametersSlice.ts | 16 +- 4 files changed, 306 insertions(+), 206 deletions(-) diff --git a/mobile/asa-go/src/slices/dataSlice.test.ts b/mobile/asa-go/src/slices/dataSlice.test.ts index 6eb495ba0e..7ddae4e6cd 100644 --- a/mobile/asa-go/src/slices/dataSlice.test.ts +++ b/mobile/asa-go/src/slices/dataSlice.test.ts @@ -1,47 +1,78 @@ vi.mock("api/fbaAPI", async () => { - const actual = await vi.importActual("api/fbaAPI"); + 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_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" + TPI_STATS_KEY: "tpiStats", })); vi.mock("@/slices/dataSliceUtils", async () => { - const actual = await vi.importActual("@/slices/dataSliceUtils"); + const actual = await vi.importActual< + typeof import("@/slices/dataSliceUtils") + >("@/slices/dataSliceUtils"); return { ...actual, fetchFireShapeAreas: vi.fn(), fetchHFIStats: vi.fn(), fetchProvincialSummaries: vi.fn(), - fetchTpiStats: vi.fn() - } -}) + fetchTpiStats: vi.fn(), + }; +}); -import {fetchFireShapeAreas, fetchHFIStats, fetchProvincialSummaries, fetchTpiStats} from "@/slices/dataSliceUtils" -import reducer, {initialState, getDataStart, getDataFailed, getDataSuccess, fetchAndCacheData, DataState } from "@/slices/dataSlice" -import { describe, it, expect, vi, Mock } from "vitest"; +import reducer, { + DataState, + fetchAndCacheData, + getDataFailed, + getDataStart, + getDataSuccess, + initialState, +} from "@/slices/dataSlice"; import { - initialState as runParametersInitialState, - } from "@/slices/runParametersSlice"; -import { DateTime } from "luxon"; -import { AdvisoryCriticalHours, AdvisoryMinWindStats, FireShapeArea, FireShapeAreaDetail, FireZoneFuelStats, FireZoneHFIStatsDictionary, FireZoneTPIStats, FuelType, HfiThreshold, RunParameter, RunType } from "api/fbaAPI"; -import { readFromFilesystem, CacheableData, FIRE_SHAPE_AREAS_KEY, PROVINCIAL_SUMMARY_KEY, TPI_STATS_KEY, HFI_STATS_KEY } from "@/utils/storage"; + fetchFireShapeAreas, + fetchHFIStats, + fetchProvincialSummaries, + fetchTpiStats, +} from "@/slices/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 { - createTestStore -} from "@/testUtils"; + 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 yesterday = DateTime.now().plus({ days: -1 }).toISODate(); const today = DateTime.now().toISODate(); const tomorrow = DateTime.now().plus({ days: 1 }).toISODate(); @@ -49,23 +80,23 @@ 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 + [today]: mockTodayRunParameter, }; const mockRunParameters: { [key: string]: RunParameter } = { @@ -78,58 +109,60 @@ const mockFireShapeArea: FireShapeArea = { threshold: 1, combustible_area: 10, elevated_hfi_area: 5, - elevated_hfi_percentage: 50 -} + elevated_hfi_percentage: 50, +}; -const mockStaleCacheableFireshapeAreas: CacheableData = { +const mockStaleCacheableFireshapeAreas: CacheableData = { [yesterday]: { runParameter: mockYesterdayRunParameter, - data: [mockFireShapeArea] + data: [mockFireShapeArea], }, [today]: { runParameter: mockTodayRunParameter, - data: [mockFireShapeArea] - } -} + data: [mockFireShapeArea], + }, +}; -const mockCacheableFireshapeAreas: CacheableData = { +const mockCacheableFireshapeAreas: CacheableData = { [today]: { runParameter: mockTodayRunParameter, - data: [mockFireShapeArea] + data: [mockFireShapeArea], }, [tomorrow]: { runParameter: mockTomorrowRunParameter, - data: [mockFireShapeArea] - } -} + data: [mockFireShapeArea], + }, +}; const mockFireShapeAreaDetail: FireShapeAreaDetail = { ...mockFireShapeArea, fire_shape_name: "test_fire_zone_unit", - fire_centre_name: "test_fire_centre" -} + fire_centre_name: "test_fire_centre", +}; -const mockStaleCacheableProvincialSummaries: CacheableData = { +const mockStaleCacheableProvincialSummaries: CacheableData< + FireShapeAreaDetail[] +> = { [yesterday]: { runParameter: mockYesterdayRunParameter, - data: [mockFireShapeAreaDetail] + data: [mockFireShapeAreaDetail], }, [today]: { runParameter: mockTodayRunParameter, - data: [mockFireShapeAreaDetail] - } -} + data: [mockFireShapeAreaDetail], + }, +}; const mockCacheableProvincialSummaries: CacheableData = { [today]: { runParameter: mockTodayRunParameter, - data: [mockFireShapeAreaDetail] + data: [mockFireShapeAreaDetail], }, [tomorrow]: { runParameter: mockTomorrowRunParameter, - data: [mockFireShapeAreaDetail] - } -} + data: [mockFireShapeAreaDetail], + }, +}; const mockFireZoneTPIStats: FireZoneTPIStats = { fire_zone_id: 1, @@ -138,67 +171,65 @@ const mockFireZoneTPIStats: FireZoneTPIStats = { mid_slope_hfi: 10, mid_slope_tpi: 10, upper_slope_hfi: 15, - upper_slope_tpi: 15 -} + upper_slope_tpi: 15, +}; const mockStaleCacheableFireZoneTPIStats: CacheableData = { [yesterday]: { runParameter: mockYesterdayRunParameter, - data: [mockFireZoneTPIStats] + data: [mockFireZoneTPIStats], }, [today]: { runParameter: mockTodayRunParameter, - data: [mockFireZoneTPIStats] - } -} + data: [mockFireZoneTPIStats], + }, +}; const mockCacheableFireZoneTPIStats: CacheableData = { [today]: { runParameter: mockTodayRunParameter, - data: [mockFireZoneTPIStats] + data: [mockFireZoneTPIStats], }, [tomorrow]: { runParameter: mockTomorrowRunParameter, - data: [mockFireZoneTPIStats] - } -} + data: [mockFireZoneTPIStats], + }, +}; const mockHFIThreshold: HfiThreshold = { id: 1, name: "test", - description: "test description" - -} + description: "test description", +}; const mockAdvisoryMinWindStats: AdvisoryMinWindStats = { threshold: mockHFIThreshold, - min_wind_speed: 5 -} + min_wind_speed: 5, +}; const mockFuelType: FuelType = { fuel_type_id: 1, fuel_type_code: "C-3", - description: "tree" -} + description: "tree", +}; const mockAdvisoryCriticalHours: AdvisoryCriticalHours = { start_time: 10, - end_time: 20 -} + end_time: 20, +}; const mockFireZoneFuelStats: FireZoneFuelStats = { fuel_type: mockFuelType, threshold: mockHFIThreshold, critical_hours: mockAdvisoryCriticalHours, area: 5, - fuel_area: 10 -} + fuel_area: 10, +}; const mockFireZoneHFIStats: FireZoneHFIStats = { min_wind_stats: [mockAdvisoryMinWindStats], - fuel_area_stats: [mockFireZoneFuelStats] -} - + fuel_area_stats: [mockFireZoneFuelStats], +}; export interface FireZoneHFIStats { min_wind_stats: AdvisoryMinWindStats[]; @@ -209,52 +240,52 @@ const mockStaleCacheableHFIStats: CacheableData = { [yesterday]: { runParameter: mockYesterdayRunParameter, data: { - 1: mockFireZoneHFIStats - } + 1: mockFireZoneHFIStats, + }, }, [today]: { runParameter: mockTodayRunParameter, data: { - 1: mockFireZoneHFIStats - } - } -} + 1: mockFireZoneHFIStats, + }, + }, +}; const mockCacheableHFIStats: CacheableData = { [today]: { runParameter: mockTodayRunParameter, data: { - 1: mockFireZoneHFIStats - } + 1: mockFireZoneHFIStats, + }, }, [tomorrow]: { runParameter: mockTomorrowRunParameter, data: { - 1: mockFireZoneHFIStats - } - } -} + 1: mockFireZoneHFIStats, + }, + }, +}; const mockStaleData = { lastUpdated: yesterday, fireShapeAreas: mockStaleCacheableFireshapeAreas, provincialSummaries: mockStaleCacheableProvincialSummaries, - tpiStats: mockStaleCacheableFireZoneTPIStats, - hfiStats: mockStaleCacheableHFIStats -} + tpiStats: mockStaleCacheableFireZoneTPIStats, + hfiStats: mockStaleCacheableHFIStats, +}; const mockData = { lastUpdated: today, fireShapeAreas: mockCacheableFireshapeAreas, provincialSummaries: mockCacheableProvincialSummaries, - tpiStats: mockCacheableFireZoneTPIStats, - hfiStats: mockCacheableHFIStats -} + tpiStats: mockCacheableFireZoneTPIStats, + hfiStats: mockCacheableHFIStats, +}; export const staleInitialState: DataState = { loading: false, error: null, - ...mockStaleData + ...mockStaleData, }; describe("data reducer", () => { @@ -272,17 +303,16 @@ describe("data reducer", () => { }); it("should handle getDataSuccess", () => { - const nextState = reducer( - initialState, - getDataSuccess({...mockData}) - ); + 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) + expect(nextState.fireShapeAreas).toEqual(mockCacheableFireshapeAreas); + expect(nextState.provincialSummaries).toEqual( + mockCacheableProvincialSummaries + ); + expect(nextState.tpiStats).toEqual(mockCacheableFireZoneTPIStats); + expect(nextState.hfiStats).toEqual(mockCacheableHFIStats); }); }); @@ -292,42 +322,48 @@ describe("fetchAndCacheData thunk", () => { switch (key) { case PROVINCIAL_SUMMARY_KEY: return { - data: mockCacheableProvincialSummaries - } + data: mockCacheableProvincialSummaries, + }; case FIRE_SHAPE_AREAS_KEY: return { - data: mockCacheableFireshapeAreas - } + data: mockCacheableFireshapeAreas, + }; case TPI_STATS_KEY: return { - data: mockCacheableFireZoneTPIStats - } + data: mockCacheableFireZoneTPIStats, + }; case HFI_STATS_KEY: return { - data: mockCacheableHFIStats - } + data: mockCacheableHFIStats, + }; } - }) - } + }); + }; const mockCacheWithNoData = () => { (readFromFilesystem as Mock).mockImplementation(() => { - return null - }) - } + return null; + }); + }; const mockAPIData = () => { - vi.mocked(fetchFireShapeAreas).mockResolvedValue(mockCacheableFireshapeAreas) - vi.mocked(fetchHFIStats).mockResolvedValue(mockCacheableHFIStats) - vi.mocked(fetchProvincialSummaries).mockResolvedValue(mockCacheableProvincialSummaries) - vi.mocked(fetchTpiStats).mockResolvedValue(mockCacheableFireZoneTPIStats) - } + 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.lastUpdated).toEqual(today); - expect(dataState.fireShapeAreas).toEqual(mockCacheableFireshapeAreas) - expect(dataState.provincialSummaries).toEqual(mockCacheableProvincialSummaries) - expect(dataState.tpiStats).toEqual(mockCacheableFireZoneTPIStats) - expect(dataState.hfiStats).toEqual(mockCacheableHFIStats) - } + 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(); @@ -335,19 +371,26 @@ describe("fetchAndCacheData thunk", () => { it("should dispatch getDataFailed when runParameters is null", async () => { const store = createTestStore({ data: { ...initialState }, - networkStatus: { networkStatus: { connected: true, connectionType: "wifi" } }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, runParameters: runParametersInitialState, }); - await store.dispatch(fetchAndCacheData()) - expect(store.getState().data.error).toMatch(/runParameters can't be null/) - }) + 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() + mockCacheWithData(); const store = createTestStore({ data: { ...initialState }, - networkStatus: { networkStatus: { connected: true, connectionType: "wifi" } }, - runParameters: {...runParametersInitialState, runParameters: mockRunParameters}, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockRunParameters, + }, }); await store.dispatch(fetchAndCacheData()); @@ -358,15 +401,20 @@ describe("fetchAndCacheData thunk", () => { expect(fetchTpiStats).not.toHaveBeenCalled(); // redux store should be updated with the cached data - const dataState = store.getState().data - testExpectedDataState(dataState) + const dataState = store.getState().data; + testExpectedDataState(dataState); }); it("should update state from cache when cache is current and state is stale", async () => { - mockCacheWithData() + mockCacheWithData(); const store = createTestStore({ data: { ...staleInitialState }, - networkStatus: { networkStatus: { connected: true, connectionType: "wifi" } }, - runParameters: {...runParametersInitialState, runParameters: mockRunParameters}, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockRunParameters, + }, }); await store.dispatch(fetchAndCacheData()); @@ -377,16 +425,21 @@ describe("fetchAndCacheData thunk", () => { expect(fetchTpiStats).not.toHaveBeenCalled(); // redux store should be updated with the cached data - const dataState = store.getState().data - testExpectedDataState(dataState) + 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() + mockAPIData(); + mockCacheWithNoData(); const store = createTestStore({ data: { ...initialState }, - networkStatus: { networkStatus: { connected: true, connectionType: "wifi" } }, - runParameters: {...runParametersInitialState, runParameters: mockRunParameters}, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockRunParameters, + }, }); await store.dispatch(fetchAndCacheData()); @@ -397,16 +450,21 @@ describe("fetchAndCacheData thunk", () => { expect(fetchTpiStats).toHaveBeenCalled(); // redux store should be updated with the fetched data - const dataState = store.getState().data - testExpectedDataState(dataState) - }) + const dataState = store.getState().data; + testExpectedDataState(dataState); + }); it("should update state from API calls when run parameters state is stale", async () => { - mockAPIData() - mockCacheWithData() + mockAPIData(); + mockCacheWithData(); const store = createTestStore({ data: { ...initialState }, - networkStatus: { networkStatus: { connected: true, connectionType: "wifi" } }, - runParameters: {...runParametersInitialState, runParameters: mockStaleRunParameters}, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockStaleRunParameters, + }, }); await store.dispatch(fetchAndCacheData()); @@ -417,19 +475,23 @@ describe("fetchAndCacheData thunk", () => { expect(fetchTpiStats).toHaveBeenCalled(); // redux store should be updated with the fetched data - const dataState = store.getState().data - testExpectedDataState(dataState) - }) + const dataState = store.getState().data; + testExpectedDataState(dataState); + }); it("should dispatch getDataFailed when state is stale and app is offline", async () => { - mockCacheWithData() + mockCacheWithData(); const store = createTestStore({ data: { ...initialState }, - networkStatus: { networkStatus: { connected: false, connectionType: "none" } }, - runParameters: {...runParametersInitialState, runParameters: mockStaleRunParameters}, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + runParameters: { + ...runParametersInitialState, + runParameters: mockStaleRunParameters, + }, }); - await store.dispatch(fetchAndCacheData()) - expect(store.getState().data.error).toMatch(/Unable to update data/) - }) -}) - + 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 index 6e3b9baaf7..799bb2ba89 100644 --- a/mobile/asa-go/src/slices/dataSlice.ts +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -1,4 +1,14 @@ -import { dataAreEqual, fetchFireShapeAreas, fetchHFIStats, fetchProvincialSummaries, fetchTpiStats, getTodayKey, getTomorrowKey, runParametersMatch, today } from "@/slices/dataSliceUtils"; +import { + dataAreEqual, + fetchFireShapeAreas, + fetchHFIStats, + fetchProvincialSummaries, + fetchTpiStats, + getTodayKey, + getTomorrowKey, + runParametersMatch, + today, +} from "@/slices/dataSliceUtils"; import { AppThunk } from "@/store"; import { CacheableData, @@ -15,7 +25,7 @@ import { FireShapeArea, FireShapeAreaDetail, FireZoneHFIStatsDictionary, - FireZoneTPIStats + FireZoneTPIStats, } from "api/fbaAPI"; import { isNil } from "lodash"; import { DateTime } from "luxon"; @@ -79,14 +89,18 @@ export const { getDataStart, getDataFailed, getDataSuccess } = export default dataSlice.reducer; export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { - const todayKey = getTodayKey() - const tomorrowKey = getTomorrowKey() + 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 + 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. @@ -94,7 +108,8 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { Filesystem, PROVINCIAL_SUMMARY_KEY ); - isCurrent = isCurrent && + isCurrent = + isCurrent && !isNil(cachedProvincialSummaries?.data) && runParametersMatch( todayKey, @@ -107,7 +122,8 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { Filesystem, FIRE_SHAPE_AREAS_KEY ); - isCurrent = isCurrent && + isCurrent = + isCurrent && !isNil(cachedFireShapeAreas?.data) && runParametersMatch( todayKey, @@ -117,7 +133,8 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { ); const cachedTPIStats = await readFromFilesystem(Filesystem, TPI_STATS_KEY); - isCurrent = isCurrent && + isCurrent = + isCurrent && !isNil(cachedTPIStats?.data) && runParametersMatch( todayKey, @@ -127,7 +144,8 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { ); const cachedHFIStats = await readFromFilesystem(Filesystem, HFI_STATS_KEY); - isCurrent = isCurrent && + isCurrent = + isCurrent && !isNil(cachedHFIStats?.data) && runParametersMatch( todayKey, diff --git a/mobile/asa-go/src/slices/runParametersSlice.test.ts b/mobile/asa-go/src/slices/runParametersSlice.test.ts index 34d1d8493f..295e909c53 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.test.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.test.ts @@ -1,24 +1,22 @@ -import { describe, it, expect, vi, Mock } from "vitest"; import reducer, { - initialState, - getRunParametersStart, + fetchSFMSRunParameters, getRunParametersFailed, + getRunParametersStart, getRunParametersSuccess, - fetchSFMSRunParameters, + initialState, selectRunParameters, - } from "@/slices/runParametersSlice"; -import { - createTestStore -} from "@/testUtils"; +} 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() + const actual = await importOriginal(); return { ...actual, getMostRecentRunParameters: vi.fn(), - } -}) + }; +}); vi.mock("@/utils/storage", () => ({ writeToFileSystem: vi.fn(), @@ -26,13 +24,13 @@ vi.mock("@/utils/storage", () => ({ RUN_PARAMETERS_CACHE_KEY: "runParameters", })); -import { getMostRecentRunParameters, RunParameter, RunType } from "api/fbaAPI"; -import { writeToFileSystem, readFromFilesystem } from "@/utils/storage"; -import { RootState } from "@/store"; import { getTodayKey, getTomorrowKey } from "@/slices/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 todayKey = getTodayKey(); +const tomorrowKey = getTomorrowKey(); const mockRunParameters: { [key: string]: RunParameter } = { [todayKey]: { @@ -78,54 +76,74 @@ describe("fetchSFMSRunParameters thunk", () => { (writeToFileSystem as Mock).mockResolvedValue(undefined); const store = createTestStore({ runParameters: { ...initialState }, - networkStatus: { networkStatus: { connected: true, connectionType: "wifi" } }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, }); - await store.dispatch(fetchSFMSRunParameters()) - expect(store.getState().runParameters.runParameters).toBe(mockRunParameters) - expect(writeToFileSystem).toBeCalled() + 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 () => { + 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" } }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, }); - await store.dispatch(fetchSFMSRunParameters()) - expect(store.getState().runParameters.runParameters).toBe(mockRunParameters) - expect(writeToFileSystem).toBeCalled() + 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)); + (getMostRecentRunParameters as Mock).mockRejectedValue( + new Error(errorMessage) + ); const store = createTestStore({ runParameters: { ...initialState }, - networkStatus: { networkStatus: { connected: true, connectionType: "wifi" } }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, }); await store.dispatch(fetchSFMSRunParameters()); - expect(store.getState().runParameters.error).toContain(errorMessage) + 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" } }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, }); await store.dispatch(fetchSFMSRunParameters()); - expect(store.getState().runParameters.runParameters).toBe(mockRunParameters); + 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" } }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, }); await store.dispatch(fetchSFMSRunParameters()); - expect(store.getState().runParameters.error).toBe("No run parameters available.") + expect(store.getState().runParameters.error).toBe( + "No run parameters available." + ); }); }); diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index b798bec6d3..f11d2c8c7c 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -60,8 +60,8 @@ export default runParameterSlice.reducer; export const fetchSFMSRunParameters = (): AppThunk => async (dispatch, getState) => { - const todayKey = getTodayKey() - const tomorrowKey = getTomorrowKey() + const todayKey = getTodayKey(); + const tomorrowKey = getTomorrowKey(); const state = getState(); const connected = state.networkStatus.networkStatus.connected; const reduxRunParameters = state.runParameters.runParameters; @@ -99,15 +99,17 @@ export const fetchSFMSRunParameters = runParameters: latestRunParameters, }) ); - return + return; } } - dispatch(getRunParametersFailed("Unable to update runParameters from the API.")) - return + dispatch( + getRunParametersFailed("Unable to update runParameters from the API.") + ); + return; } catch (err) { dispatch(getRunParametersFailed((err as Error).toString())); console.log(err); - return + return; } } else { // We're offline, so check the cache for existing run parameters and update state with the @@ -137,7 +139,7 @@ export const fetchSFMSRunParameters = runParameters: cachedRunParameters, }) ); - return + return; } // We're offline and there are no cached run parameters for today dispatch(getRunParametersFailed("No run parameters available.")); From 1057de2141c48d356868ad9bee86cc58267420e3 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 24 Nov 2025 15:03:28 -0800 Subject: [PATCH 31/40] Fix tests --- mobile/asa-go/src/slices/dataSlice.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/asa-go/src/slices/dataSlice.test.ts b/mobile/asa-go/src/slices/dataSlice.test.ts index 7ddae4e6cd..658e358e69 100644 --- a/mobile/asa-go/src/slices/dataSlice.test.ts +++ b/mobile/asa-go/src/slices/dataSlice.test.ts @@ -356,7 +356,6 @@ describe("fetchAndCacheData thunk", () => { }; const testExpectedDataState = (dataState: DataState) => { expect(dataState.error).toBeNull(); - expect(dataState.lastUpdated).toEqual(today); expect(dataState.fireShapeAreas).toEqual(mockCacheableFireshapeAreas); expect(dataState.provincialSummaries).toEqual( mockCacheableProvincialSummaries From 8fdd0277d885357ef453d26f4f8ce77a632960eb Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 13:21:11 -0800 Subject: [PATCH 32/40] PR feedback --- api/app/routers/fba.py | 16 ++++++++-------- api/app/tests/fba/test_fba_endpoint.py | 6 ------ mobile/asa-go/src/App.tsx | 2 +- .../src/components/TodayTomorrowSwitch.tsx | 2 +- .../src/components/report/AdvisoryText.tsx | 2 +- mobile/asa-go/src/slices/dataSlice.test.ts | 4 ++-- mobile/asa-go/src/slices/dataSlice.ts | 2 +- mobile/asa-go/src/slices/fireCentersSlice.ts | 2 +- .../asa-go/src/slices/runParametersSlice.test.ts | 2 +- mobile/asa-go/src/slices/runParametersSlice.ts | 2 +- .../src/{slices => utils}/dataSliceUtils.test.ts | 2 +- .../src/{slices => utils}/dataSliceUtils.ts | 0 12 files changed, 18 insertions(+), 24 deletions(-) rename mobile/asa-go/src/{slices => utils}/dataSliceUtils.test.ts (99%) rename mobile/asa-go/src/{slices => utils}/dataSliceUtils.ts (100%) diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index 08add7d04f..e4c129b93e 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -68,7 +68,7 @@ async def get_all_zone_data_for_source_ids( session: AsyncSession, - zone_source_ids: List[SFMSFuelType], + zone_source_ids: List[str], run_type: RunType, for_date: date, run_datetime: datetime, @@ -174,14 +174,14 @@ async def get_shapes( # 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, ) ) @@ -355,7 +355,7 @@ async def get_run_datetimes_for_date_and_runtype( rows = await get_run_datetimes(session, RunTypeEnum(run_type.value), for_date) for row in rows: - datetimes.append(row.run_datetime) # type: ignore + datetimes.append(row.run_datetime) return datetimes diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index 0d334e9c2b..97d7d82379 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -201,7 +201,6 @@ async def mock_get_auth_header(*_, **__): async def mock_get_tpi_stats_empty(*_, **__): - await asyncio.sleep(0) return mock_tpi_stats_empty @@ -245,12 +244,10 @@ async def mock_get_sfms_bounds(*_, **__): async def mock_get_sfms_bounds_no_data(*_, **__): - await asyncio.sleep(0) return [] async def mock_get_most_recent_run_datetime_for_date_range(*_, **__): - await asyncio.sleep(0) for_date_1 = date(2025, 8, 25) for_date_2 = date(2025, 8, 26) run_datetime = datetime(2025, 8, 25) @@ -264,17 +261,14 @@ async def mock_get_most_recent_run_datetime_for_date_range(*_, **__): async def mock_get_all_zone_source_ids(*_, **__): - await asyncio.sleep(0) return [1, 2, 3] async def mock_get_tpi_fuel_areas(*_, **__): - await asyncio.sleep(0) 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(*_, **__): - await asyncio.sleep(0) mock_fire_zone_hfi_stats = FireZoneHFIStats(min_wind_stats=[], fuel_area_stats=[]) return HFIStatsResponse(zone_data={1: mock_fire_zone_hfi_stats}) diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index a68329c2c4..2a846c27b1 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -8,7 +8,7 @@ import TabPanel from "@/components/TabPanel"; import { useAppIsActive } from "@/hooks/useAppIsActive"; import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; import { fetchAndCacheData } from "@/slices/dataSlice"; -import { today } from "@/slices/dataSliceUtils"; +import { today } from "@/utils/dataSliceUtils"; import { fetchFireCenters } from "@/slices/fireCentersSlice"; import { startWatchingLocation, diff --git a/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx b/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx index 921e70167a..6ad7d56d5b 100644 --- a/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx +++ b/mobile/asa-go/src/components/TodayTomorrowSwitch.tsx @@ -3,7 +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 "@/slices/dataSliceUtils"; +import { today } from "@/utils/dataSliceUtils"; interface TodayTomorrowSwitchProps { border?: boolean; diff --git a/mobile/asa-go/src/components/report/AdvisoryText.tsx b/mobile/asa-go/src/components/report/AdvisoryText.tsx index 0d7ec42399..8e553dcd75 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -10,7 +10,7 @@ import { useProvincialSummaryForDate, } from "@/hooks/dataHooks"; import { useRunParameterForDate } from "@/hooks/useRunParameterForDate"; -import { today } from "@/slices/dataSliceUtils"; +import { today } from "@/utils/dataSliceUtils"; import { getTopFuelsByArea, getTopFuelsByProportion, diff --git a/mobile/asa-go/src/slices/dataSlice.test.ts b/mobile/asa-go/src/slices/dataSlice.test.ts index 658e358e69..c0c5a3b6be 100644 --- a/mobile/asa-go/src/slices/dataSlice.test.ts +++ b/mobile/asa-go/src/slices/dataSlice.test.ts @@ -21,7 +21,7 @@ vi.mock("@/utils/storage", () => ({ vi.mock("@/slices/dataSliceUtils", async () => { const actual = await vi.importActual< - typeof import("@/slices/dataSliceUtils") + typeof import("@/utils/dataSliceUtils") >("@/slices/dataSliceUtils"); return { ...actual, @@ -45,7 +45,7 @@ import { fetchHFIStats, fetchProvincialSummaries, fetchTpiStats, -} from "@/slices/dataSliceUtils"; +} from "@/utils/dataSliceUtils"; import { initialState as runParametersInitialState } from "@/slices/runParametersSlice"; import { createTestStore } from "@/testUtils"; import { diff --git a/mobile/asa-go/src/slices/dataSlice.ts b/mobile/asa-go/src/slices/dataSlice.ts index 799bb2ba89..f593f70b28 100644 --- a/mobile/asa-go/src/slices/dataSlice.ts +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -8,7 +8,7 @@ import { getTomorrowKey, runParametersMatch, today, -} from "@/slices/dataSliceUtils"; +} from "@/utils/dataSliceUtils"; import { AppThunk } from "@/store"; import { CacheableData, diff --git a/mobile/asa-go/src/slices/fireCentersSlice.ts b/mobile/asa-go/src/slices/fireCentersSlice.ts index 17f79f5469..636ceee499 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.ts @@ -1,4 +1,4 @@ -import { today } from "@/slices/dataSliceUtils"; +import { today } from "@/utils/dataSliceUtils"; import { AppThunk } from "@/store"; import { FIRE_CENTERS_CACHE_EXPIRATION, diff --git a/mobile/asa-go/src/slices/runParametersSlice.test.ts b/mobile/asa-go/src/slices/runParametersSlice.test.ts index 295e909c53..3efe707400 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.test.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.test.ts @@ -24,7 +24,7 @@ vi.mock("@/utils/storage", () => ({ RUN_PARAMETERS_CACHE_KEY: "runParameters", })); -import { getTodayKey, getTomorrowKey } from "@/slices/dataSliceUtils"; +import { getTodayKey, getTomorrowKey } from "@/utils/dataSliceUtils"; import { RootState } from "@/store"; import { readFromFilesystem, writeToFileSystem } from "@/utils/storage"; import { getMostRecentRunParameters, RunParameter, RunType } from "api/fbaAPI"; diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index f11d2c8c7c..a0eac22872 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -1,4 +1,4 @@ -import { getTodayKey, getTomorrowKey, today } from "@/slices/dataSliceUtils"; +import { getTodayKey, getTomorrowKey, today } from "@/utils/dataSliceUtils"; import { AppThunk, RootState } from "@/store"; import { readFromFilesystem, diff --git a/mobile/asa-go/src/slices/dataSliceUtils.test.ts b/mobile/asa-go/src/utils/dataSliceUtils.test.ts similarity index 99% rename from mobile/asa-go/src/slices/dataSliceUtils.test.ts rename to mobile/asa-go/src/utils/dataSliceUtils.test.ts index d0a06ca429..cb48c49066 100644 --- a/mobile/asa-go/src/slices/dataSliceUtils.test.ts +++ b/mobile/asa-go/src/utils/dataSliceUtils.test.ts @@ -17,7 +17,7 @@ import { fetchTpiStatsForRunParameter, runParametersMatch, shapeDataForCaching, -} from "@/slices/dataSliceUtils"; // adjust path +} from "@/utils/dataSliceUtils"; import { CacheableData } from "@/utils/storage"; import { DateTime } from "luxon"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; diff --git a/mobile/asa-go/src/slices/dataSliceUtils.ts b/mobile/asa-go/src/utils/dataSliceUtils.ts similarity index 100% rename from mobile/asa-go/src/slices/dataSliceUtils.ts rename to mobile/asa-go/src/utils/dataSliceUtils.ts From a331dacf69b1055d0da75d0072ceb92f6224d754 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 13:49:06 -0800 Subject: [PATCH 33/40] Use cached fire centers if offline. --- mobile/asa-go/src/slices/fireCentersSlice.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/asa-go/src/slices/fireCentersSlice.ts b/mobile/asa-go/src/slices/fireCentersSlice.ts index 636ceee499..c00e4ae485 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.ts @@ -65,15 +65,16 @@ export const fetchFireCenters = (): AppThunk => async (dispatch, getState) => { Filesystem, FIRE_CENTERS_KEY ); + const networkStatus = getState().networkStatus; if (!isNull(cachedFireCenters)) { const lastUpdated = DateTime.fromISO(cachedFireCenters.lastUpdated); - if (lastUpdated.plus({ hours: FIRE_CENTERS_CACHE_EXPIRATION }) > today) { + // 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)); 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.networkStatus.connected) { try { dispatch(getFireCentersStart()); From 44be9c1a8ae711848d43cddb86d821bc99f72fe5 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 13:49:56 -0800 Subject: [PATCH 34/40] Add missing dependency to zoneStatus hook. --- mobile/asa-go/src/components/report/AdvisoryText.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/asa-go/src/components/report/AdvisoryText.tsx b/mobile/asa-go/src/components/report/AdvisoryText.tsx index 8e553dcd75..4c7153354f 100644 --- a/mobile/asa-go/src/components/report/AdvisoryText.tsx +++ b/mobile/asa-go/src/components/report/AdvisoryText.tsx @@ -107,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 From 1710904b125cad042bb5176548e7c3522721905f Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 14:48:59 -0800 Subject: [PATCH 35/40] Add return type to readFromFilesystem --- mobile/asa-go/src/slices/dataSlice.ts | 21 +++++++++++++------- mobile/asa-go/src/slices/fireCentersSlice.ts | 2 +- mobile/asa-go/src/utils/storage.ts | 11 ++++++---- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/mobile/asa-go/src/slices/dataSlice.ts b/mobile/asa-go/src/slices/dataSlice.ts index f593f70b28..6ade7a20ce 100644 --- a/mobile/asa-go/src/slices/dataSlice.ts +++ b/mobile/asa-go/src/slices/dataSlice.ts @@ -1,3 +1,4 @@ +import { AppThunk } from "@/store"; import { dataAreEqual, fetchFireShapeAreas, @@ -9,9 +10,9 @@ import { runParametersMatch, today, } from "@/utils/dataSliceUtils"; -import { AppThunk } from "@/store"; import { CacheableData, + CachedData, FIRE_SHAPE_AREAS_KEY, HFI_STATS_KEY, PROVINCIAL_SUMMARY_KEY, @@ -104,10 +105,10 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { } // 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( + const cachedProvincialSummaries = (await readFromFilesystem( Filesystem, PROVINCIAL_SUMMARY_KEY - ); + )) as CachedData>; isCurrent = isCurrent && !isNil(cachedProvincialSummaries?.data) && @@ -118,10 +119,10 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { cachedProvincialSummaries.data ); - const cachedFireShapeAreas = await readFromFilesystem( + const cachedFireShapeAreas = (await readFromFilesystem( Filesystem, FIRE_SHAPE_AREAS_KEY - ); + )) as CachedData>; isCurrent = isCurrent && !isNil(cachedFireShapeAreas?.data) && @@ -132,7 +133,10 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { cachedFireShapeAreas.data ); - const cachedTPIStats = await readFromFilesystem(Filesystem, TPI_STATS_KEY); + const cachedTPIStats = (await readFromFilesystem( + Filesystem, + TPI_STATS_KEY + )) as CachedData>; isCurrent = isCurrent && !isNil(cachedTPIStats?.data) && @@ -143,7 +147,10 @@ export const fetchAndCacheData = (): AppThunk => async (dispatch, getState) => { cachedTPIStats.data ); - const cachedHFIStats = await readFromFilesystem(Filesystem, HFI_STATS_KEY); + const cachedHFIStats = (await readFromFilesystem( + Filesystem, + HFI_STATS_KEY + )) as CachedData>; isCurrent = isCurrent && !isNil(cachedHFIStats?.data) && diff --git a/mobile/asa-go/src/slices/fireCentersSlice.ts b/mobile/asa-go/src/slices/fireCentersSlice.ts index c00e4ae485..b4f1993bff 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.ts @@ -70,7 +70,7 @@ export const fetchFireCenters = (): AppThunk => async (dispatch, getState) => { 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)); + dispatch(getFireCentersSuccess(cachedFireCenters.data as FireCenter[])); return; } } diff --git a/mobile/asa-go/src/utils/storage.ts b/mobile/asa-go/src/utils/storage.ts index 49d0b4b76f..aa808fdc15 100644 --- a/mobile/asa-go/src/utils/storage.ts +++ b/mobile/asa-go/src/utils/storage.ts @@ -1,6 +1,5 @@ import { FireCenter, - FireCentreTPIResponse, FireShapeArea, FireShapeAreaDetail, FireZoneHFIStatsDictionary, @@ -24,11 +23,15 @@ export type CacheableData = { }; type Cacheable = - | FireShapeAreaDetail[] - | FireCentreTPIResponse | 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"; @@ -62,7 +65,7 @@ export const writeToFileSystem = async ( export const readFromFilesystem = async ( filesystem: FilesystemPlugin, key: string -) => { +): Promise | Cacheable> | null> => { try { const result = await filesystem.readFile({ path: getPath(key), From f1e9ba9b5a0cb592850f8dfd0c83b0d28408137f Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 14:53:29 -0800 Subject: [PATCH 36/40] Typings --- mobile/asa-go/src/slices/runParametersSlice.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/asa-go/src/slices/runParametersSlice.ts b/mobile/asa-go/src/slices/runParametersSlice.ts index a0eac22872..eceff4c5f4 100644 --- a/mobile/asa-go/src/slices/runParametersSlice.ts +++ b/mobile/asa-go/src/slices/runParametersSlice.ts @@ -118,11 +118,11 @@ export const fetchSFMSRunParameters = Filesystem, RUN_PARAMETERS_CACHE_KEY ); - const cachedRunParameters: { [key: string]: RunParameter } = isNil( + const cachedRunParameters: { [key: string]: RunParameter } | null = isNil( cachedData ) ? null - : cachedData.data; + : cachedData.data as { [key: string]: RunParameter }; if ( !isNil(cachedRunParameters) && (isNil(reduxRunParameters) || From 326599f731a93b9a5bb973907298aa8d416f2486 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 16:12:14 -0800 Subject: [PATCH 37/40] test fixes --- mobile/asa-go/src/slices/dataSlice.test.ts | 4 ++-- .../asa-go/src/slices/fireCentersSlice.test.ts | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/mobile/asa-go/src/slices/dataSlice.test.ts b/mobile/asa-go/src/slices/dataSlice.test.ts index c0c5a3b6be..2664abc744 100644 --- a/mobile/asa-go/src/slices/dataSlice.test.ts +++ b/mobile/asa-go/src/slices/dataSlice.test.ts @@ -19,10 +19,10 @@ vi.mock("@/utils/storage", () => ({ TPI_STATS_KEY: "tpiStats", })); -vi.mock("@/slices/dataSliceUtils", async () => { +vi.mock("@/utils/dataSliceUtils", async () => { const actual = await vi.importActual< typeof import("@/utils/dataSliceUtils") - >("@/slices/dataSliceUtils"); + >("@/utils/dataSliceUtils"); return { ...actual, fetchFireShapeAreas: vi.fn(), diff --git a/mobile/asa-go/src/slices/fireCentersSlice.test.ts b/mobile/asa-go/src/slices/fireCentersSlice.test.ts index c35999c3c8..930ab34931 100644 --- a/mobile/asa-go/src/slices/fireCentersSlice.test.ts +++ b/mobile/asa-go/src/slices/fireCentersSlice.test.ts @@ -149,7 +149,7 @@ describe("fetchFireCenters thunk", () => { expect(state.error).toMatch(/Unable to refresh fire center data/); }); - it("should dispatch error when cache is stale and app is offline", async () => { + it("should dispatch success when cache is stale and app is offline", async () => { mockCacheWithData(true); const store = createTestStore({ fireCenters: { ...initialState }, @@ -160,6 +160,20 @@ describe("fetchFireCenters thunk", () => { 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/); }); }); From 896d4b504a2858bbf18bd2a46dea0f94b85b1863 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 16:25:47 -0800 Subject: [PATCH 38/40] Don't set zone on today --- mobile/asa-go/src/utils/dataSliceUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/asa-go/src/utils/dataSliceUtils.ts b/mobile/asa-go/src/utils/dataSliceUtils.ts index 7da916d1e4..01a54da545 100644 --- a/mobile/asa-go/src/utils/dataSliceUtils.ts +++ b/mobile/asa-go/src/utils/dataSliceUtils.ts @@ -9,12 +9,12 @@ import { 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 today = DateTime.now(); +// export const today = DateTime.fromISO("2025-08-11"); export const getTodayKey = () => { return today.isValid ? today.toISODate() : ""; }; From ccaa277dab7c5a51c94bd8c538cca3658924e3db Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 16:30:05 -0800 Subject: [PATCH 39/40] Better fix --- .../components/todayTomorrowSwitch.test.tsx | 52 ++++++++++--------- mobile/asa-go/src/utils/dataSliceUtils.ts | 4 +- 2 files changed, 30 insertions(+), 26 deletions(-) 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/utils/dataSliceUtils.ts b/mobile/asa-go/src/utils/dataSliceUtils.ts index 01a54da545..7da916d1e4 100644 --- a/mobile/asa-go/src/utils/dataSliceUtils.ts +++ b/mobile/asa-go/src/utils/dataSliceUtils.ts @@ -9,12 +9,12 @@ import { 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(); -// export const today = DateTime.fromISO("2025-08-11"); +export const today = DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`); export const getTodayKey = () => { return today.isValid ? today.toISODate() : ""; }; From 86d85f609268acb0608f4a128d0e81ac7769b7ae Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 27 Nov 2025 16:39:10 -0800 Subject: [PATCH 40/40] Another test fix --- mobile/asa-go/src/components/report/advisoryText.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/asa-go/src/components/report/advisoryText.test.tsx b/mobile/asa-go/src/components/report/advisoryText.test.tsx index 7db09c41b7..d35b82d1df 100644 --- a/mobile/asa-go/src/components/report/advisoryText.test.tsx +++ b/mobile/asa-go/src/components/report/advisoryText.test.tsx @@ -35,6 +35,7 @@ vi.mock(import("@/hooks/dataHooks"), async (importOriginal) => { }); 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"; @@ -534,7 +535,7 @@ describe("AdvisoryText", () => { const todayRunParameter = cloneDeep(testRunParameter); (useRunParameterForDate as Mock).mockReturnValue({ ...todayRunParameter, - for_date: DateTime.now().toISODate(), + for_date: DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`).toISODate(), }); (useFilteredHFIStatsForDate as Mock).mockReturnValue({}); const store = buildTestStore(