diff --git a/web/src/app/rootReducer.ts b/web/src/app/rootReducer.ts index 1153a6667..9652e0031 100644 --- a/web/src/app/rootReducer.ts +++ b/web/src/app/rootReducer.ts @@ -66,7 +66,7 @@ export const selectFireCenters = (state: RootState) => state.fireCenters export const selectFireShapeAreas = (state: RootState) => state.fireShapeAreas export const selectRunDates = (state: RootState) => state.runDates export const selectValueAtCoordinate = (state: RootState) => state.valueAtCoordinate -export const selectFireCentreHFIFuelTypes = (state: RootState) => state.fireCentreHFIFuelStats +export const selectFireCentreHFIFuelStats = (state: RootState) => state.fireCentreHFIFuelStats export const selectFireZoneElevationInfo = (state: RootState) => state.fireZoneElevationInfo export const selectFireCentreTPIStats = (state: RootState) => state.fireCentreTPIStats export const selectHFIDailiesLoading = (state: RootState): boolean => state.hfiCalculatorDailies.fireCentresLoading diff --git a/web/src/features/fba/calculateZoneStatus.ts b/web/src/features/fba/calculateZoneStatus.ts index abb8a26fd..fd0ae9c7e 100644 --- a/web/src/features/fba/calculateZoneStatus.ts +++ b/web/src/features/fba/calculateZoneStatus.ts @@ -1,6 +1,7 @@ import { FireShapeAreaDetail } from '@/api/fbaAPI' import { ADVISORY_ORANGE_FILL, ADVISORY_RED_FILL } from '@/features/fba/components/map/featureStylers' import { AdvisoryStatus } from '@/utils/constants' +import { isUndefined } from 'lodash' export const calculateStatusColour = ( details: FireShapeAreaDetail[], @@ -33,6 +34,10 @@ export const calculateStatusText = ( details: FireShapeAreaDetail[], advisoryThreshold: number ): AdvisoryStatus | undefined => { + if (isUndefined(details) || details.length === 0) { + return undefined + } + const advisoryThresholdDetail = details.find(detail => detail.threshold == 1) const warningThresholdDetail = details.find(detail => detail.threshold == 2) const advisoryPercentage = advisoryThresholdDetail?.elevated_hfi_percentage ?? 0 diff --git a/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx b/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx index 4ef800252..70186a8ca 100644 --- a/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx +++ b/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx @@ -1,5 +1,5 @@ import { Box, Tabs, Tab, Grid } from '@mui/material' -import { FireCenter } from 'api/fbaAPI' +import { FireCenter, FireShape } from 'api/fbaAPI' import { INFO_PANEL_CONTENT_BACKGROUND } from 'app/theme' import AdvisoryText from 'features/fba/components/infoPanel/AdvisoryText' import InfoAccordion from 'features/fba/components/infoPanel/InfoAccordion' @@ -11,6 +11,7 @@ interface AdvisoryReportProps { forDate: DateTime advisoryThreshold: number selectedFireCenter?: FireCenter + selectedFireZoneUnit?: FireShape } interface TabPanelProps { @@ -27,7 +28,7 @@ const TabPanel = ({ children, index, value }: TabPanelProps) => { ) } -const AdvisoryReport = ({ issueDate, forDate, advisoryThreshold, selectedFireCenter }: AdvisoryReportProps) => { +const AdvisoryReport = ({ issueDate, forDate, advisoryThreshold, selectedFireCenter, selectedFireZoneUnit }: AdvisoryReportProps) => { const [tabNumber, setTabNumber] = useState(0) const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { @@ -54,6 +55,7 @@ const AdvisoryReport = ({ issueDate, forDate, advisoryThreshold, selectedFireCen forDate={forDate} advisoryThreshold={advisoryThreshold} selectedFireCenter={selectedFireCenter} + selectedFireZoneUnit={selectedFireZoneUnit} > diff --git a/web/src/features/fba/components/infoPanel/AdvisoryText.tsx b/web/src/features/fba/components/infoPanel/AdvisoryText.tsx index 520a6469b..862bac3fd 100644 --- a/web/src/features/fba/components/infoPanel/AdvisoryText.tsx +++ b/web/src/features/fba/components/infoPanel/AdvisoryText.tsx @@ -1,11 +1,12 @@ import { Box, Typography } from '@mui/material' -import { FireCenter, FireShapeAreaDetail } from 'api/fbaAPI' +import { FireCenter, FireShape, FireZoneFuelStats } from 'api/fbaAPI' import { DateTime } from 'luxon' -import React from 'react' +import React, { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { selectProvincialSummary } from 'features/fba/slices/provincialSummarySlice' +import { selectFireCentreHFIFuelStats } from '@/app/rootReducer' import { AdvisoryStatus } from 'utils/constants' -import { groupBy } from 'lodash' +import { isEmpty, isNil, isUndefined, take } from 'lodash' import { calculateStatusText } from '@/features/fba/calculateZoneStatus' interface AdvisoryTextProps { @@ -13,27 +14,83 @@ interface AdvisoryTextProps { forDate: DateTime selectedFireCenter?: FireCenter advisoryThreshold: number + selectedFireZoneUnit?: FireShape } -const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCenter }: AdvisoryTextProps) => { +const AdvisoryText = ({ + issueDate, + forDate, + selectedFireCenter, + advisoryThreshold, + selectedFireZoneUnit +}: AdvisoryTextProps) => { const provincialSummary = useSelector(selectProvincialSummary) + const { fireCentreHFIFuelStats } = useSelector(selectFireCentreHFIFuelStats) + const [selectedFireZoneUnitTopFuels, setSelectedFireZoneUnitTopFuels] = useState([]) - const getZoneStatusMap = (fireZoneUnitDetails: Record) => { - const zoneStatusMap: Record = { - [AdvisoryStatus.ADVISORY]: [], - [AdvisoryStatus.WARNING]: [] + const [minStartTime, setMinStartTime] = useState(undefined) + const [maxEndTime, setMaxEndTime] = useState(undefined) + + const sortByArea = (a: FireZoneFuelStats, b: FireZoneFuelStats) => { + if (a.area > b.area) { + return -1 + } + if (a.area < b.area) { + return 1 } + return 0 + } - for (const zoneUnit in fireZoneUnitDetails) { - const fireShapeAreaDetails: FireShapeAreaDetail[] = fireZoneUnitDetails[zoneUnit] - const status = calculateStatusText(fireShapeAreaDetails, advisoryThreshold) + useEffect(() => { + if ( + isUndefined(fireCentreHFIFuelStats) || + isEmpty(fireCentreHFIFuelStats) || + isUndefined(selectedFireCenter) || + isUndefined(selectedFireZoneUnit) + ) { + setSelectedFireZoneUnitTopFuels([]) + setMinStartTime(undefined) + setMaxEndTime(undefined) + return + } + const allZoneUnitFuelStats = fireCentreHFIFuelStats?.[selectedFireCenter.name] + const selectedZoneUnitFuelStats = allZoneUnitFuelStats?.[selectedFireZoneUnit.fire_shape_id] ?? [] + const sortedFuelStats = [...selectedZoneUnitFuelStats].sort(sortByArea) + let topFuels = take(sortedFuelStats, 3) + setSelectedFireZoneUnitTopFuels(topFuels) + }, [fireCentreHFIFuelStats]) - if (status) { - zoneStatusMap[status].push(zoneUnit) + useEffect(() => { + let startTime: number | undefined = undefined + let endTime: number | undefined = undefined + for (const fuel of selectedFireZoneUnitTopFuels) { + if (!isUndefined(fuel.critical_hours.start_time)) { + if (isUndefined(startTime) || fuel.critical_hours.start_time < startTime) { + startTime = fuel.critical_hours.start_time + } + } + if (!isUndefined(fuel.critical_hours.end_time)) { + if (isUndefined(endTime) || fuel.critical_hours.end_time > endTime) { + endTime = fuel.critical_hours.end_time + } } } + setMinStartTime(startTime) + setMaxEndTime(endTime) + }, [selectedFireZoneUnitTopFuels]) - return zoneStatusMap + const getTopFuelsString = () => { + const topFuelCodes = selectedFireZoneUnitTopFuels.map(topFuel => topFuel.fuel_type.fuel_type_code) + switch (topFuelCodes.length) { + case 1: + return `fuel type ${topFuelCodes[0]}` + case 2: + return `fuel types ${topFuelCodes[0]} and ${topFuelCodes[1]}` + case 3: + return `fuel types ${topFuelCodes[0]}, ${topFuelCodes[1]} and ${topFuelCodes[2]}` + default: + return '' + } } const renderDefaultMessage = () => { @@ -53,8 +110,15 @@ const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCente const displayForDate = forToday ? 'today' : forDate.toLocaleString({ month: 'short', day: 'numeric' }) const fireCenterSummary = provincialSummary[selectedFireCenter!.name] - const groupedFireZoneUnitInfos = groupBy(fireCenterSummary, 'fire_shape_name') - const zoneStatusMap = getZoneStatusMap(groupedFireZoneUnitInfos) + const fireZoneUnitInfos = fireCenterSummary?.filter(fc => fc.fire_shape_id === selectedFireZoneUnit?.fire_shape_id) + const zoneStatus = calculateStatusText(fireZoneUnitInfos, advisoryThreshold) + const hasCriticalHours = !isNil(minStartTime) && !isNil(maxEndTime) && selectFireCentreHFIFuelStats.length > 0 + let message = '' + if (hasCriticalHours) { + message = `There is a fire behaviour ${zoneStatus} in effect for ${selectedFireZoneUnit?.mof_fire_zone_name} between ${minStartTime}:00 and ${maxEndTime}:00 for ${getTopFuelsString()}.` + } else { + message = `There is a fire behaviour ${zoneStatus} in effect for ${selectedFireZoneUnit?.mof_fire_zone_name}.` + } return ( <> @@ -63,33 +127,20 @@ const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCente sx={{ whiteSpace: 'pre-wrap' }} >{`Issued on ${issueDate?.toLocaleString(DateTime.DATE_MED)} for ${displayForDate}.\n\n`} )} - {zoneStatusMap[AdvisoryStatus.WARNING].length > 0 && ( - <> - {`There is a fire behaviour ${AdvisoryStatus.WARNING} in effect in the following areas:`} -
    - {zoneStatusMap[AdvisoryStatus.WARNING].map(zone => ( -
  • - {zone} -
  • - ))} -
- + {!isUndefined(zoneStatus) && zoneStatus === AdvisoryStatus.ADVISORY && ( + {message} )} - {zoneStatusMap[AdvisoryStatus.ADVISORY].length > 0 && ( - <> - {`There is a fire behaviour ${AdvisoryStatus.ADVISORY} in effect in the following areas:`} -
    - {zoneStatusMap[AdvisoryStatus.ADVISORY].map(zone => ( -
  • - {zone} -
  • - ))} -
- + {!isUndefined(zoneStatus) && zoneStatus === AdvisoryStatus.WARNING && ( + {message} + )} + {!hasCriticalHours && ( + + No critical hours available. + )} - {zoneStatusMap[AdvisoryStatus.WARNING].length === 0 && zoneStatusMap[AdvisoryStatus.ADVISORY].length === 0 && ( + {isUndefined(zoneStatus) && ( - No advisories or warnings issued for the selected fire center. + No advisories or warnings issued for the selected fire zone unit. )} @@ -109,7 +160,9 @@ const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCente backgroundColor: 'white' }} > - {!selectedFireCenter || !issueDate?.isValid ? renderDefaultMessage() : renderAdvisoryText()} + {!selectedFireCenter || !issueDate?.isValid || !selectedFireZoneUnit + ? renderDefaultMessage() + : renderAdvisoryText()} ) diff --git a/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx b/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx index 0a9404c7e..23ffc7088 100644 --- a/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx +++ b/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx @@ -1,4 +1,4 @@ -import { selectFireCentreHFIFuelTypes, selectFireCentreTPIStats } from '@/app/rootReducer' +import { selectFireCentreHFIFuelStats, selectFireCentreTPIStats } from '@/app/rootReducer' import { calculateStatusColour } from '@/features/fba/calculateZoneStatus' import { Box, Grid, Tab, Tabs, Tooltip } from '@mui/material' import { FireCenter, FireShape } from 'api/fbaAPI' @@ -27,7 +27,7 @@ const FireZoneUnitTabs = ({ setSelectedFireShape }: FireZoneUnitTabs) => { const { fireCentreTPIStats } = useSelector(selectFireCentreTPIStats) - const { fireCentreHFIFuelStats } = useSelector(selectFireCentreHFIFuelTypes) + const { fireCentreHFIFuelStats } = useSelector(selectFireCentreHFIFuelStats) const [tabNumber, setTabNumber] = useState(0) const sortedGroupedFireZoneUnits = useFireCentreDetails(selectedFireCenter) diff --git a/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx b/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx index 015c5f589..f66adc502 100644 --- a/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx +++ b/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx @@ -6,11 +6,16 @@ import provincialSummarySlice, { initialState, ProvincialSummaryState } from 'features/fba/slices/provincialSummarySlice' +import fireCentreHFIFuelStatsSlice from '@/features/fba/slices/fireCentreHFIFuelStatsSlice' + import { combineReducers, configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' const buildTestStore = (initialState: ProvincialSummaryState) => { - const rootReducer = combineReducers({ provincialSummary: provincialSummarySlice }) + const rootReducer = combineReducers({ + provincialSummary: provincialSummarySlice, + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice + }) const testStore = configureStore({ reducer: rootReducer, preloadedState: { diff --git a/web/src/features/fba/components/infoPanel/advisoryText.test.tsx b/web/src/features/fba/components/infoPanel/advisoryText.test.tsx index 54b6d4591..ba1437876 100644 --- a/web/src/features/fba/components/infoPanel/advisoryText.test.tsx +++ b/web/src/features/fba/components/infoPanel/advisoryText.test.tsx @@ -1,20 +1,31 @@ import { render } from '@testing-library/react' import { DateTime } from 'luxon' import AdvisoryText from 'features/fba/components/infoPanel/AdvisoryText' -import { FireCenter, FireShapeAreaDetail } from 'api/fbaAPI' +import { FireCenter, FireShape, FireShapeAreaDetail } from 'api/fbaAPI' import provincialSummarySlice, { - initialState, + initialState as provSummaryInitialState, ProvincialSummaryState } from 'features/fba/slices/provincialSummarySlice' +import fireCentreHFIFuelStatsSlice, { + initialState as fuelStatsInitialState, + FireCentreHFIFuelStatsState +} from '@/features/fba/slices/fireCentreHFIFuelStatsSlice' import { combineReducers, configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' -const buildTestStore = (initialState: ProvincialSummaryState) => { - const rootReducer = combineReducers({ provincialSummary: provincialSummarySlice }) +const buildTestStore = ( + provincialSummaryInitialState: ProvincialSummaryState, + fuelStatsInitialState?: FireCentreHFIFuelStatsState +) => { + const rootReducer = combineReducers({ + provincialSummary: provincialSummarySlice, + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice + }) const testStore = configureStore({ reducer: rootReducer, preloadedState: { - provincialSummary: initialState + provincialSummary: provincialSummaryInitialState, + fireCentreHFIFuelStats: fuelStatsInitialState } }) return testStore @@ -30,6 +41,20 @@ const mockFireCenter: FireCenter = { stations: [] } +const mockFireZoneUnit: FireShape = { + fire_shape_id: 20, + mof_fire_zone_name: 'C2-Central Cariboo Fire Zone', + mof_fire_centre_name: 'Cariboo Fire Centre', + area_sqm: undefined +} + +const mockAdvisoryFireZoneUnit: FireShape = { + fire_shape_id: 18, + mof_fire_zone_name: 'C4-100 Mile House Fire Zone', + mof_fire_centre_name: 'Cariboo Fire Centre', + area_sqm: undefined +} + const advisoryDetails: FireShapeAreaDetail[] = [ { fire_shape_id: 18, @@ -95,19 +120,14 @@ const noAdvisoryDetails: FireShapeAreaDetail[] = [ describe('AdvisoryText', () => { const testStore = buildTestStore({ - ...initialState, + ...provSummaryInitialState, fireShapeAreaDetails: advisoryDetails }) it('should render the advisory text container', () => { const { getByTestId } = render( - + ) const advisoryText = getByTestId('advisory-text') @@ -124,7 +144,22 @@ describe('AdvisoryText', () => { expect(message).toBeInTheDocument() }) - it('should render no data message when the issueDate is invalid selected', () => { + it('should render default message when no fire zone unit is selected', () => { + const { getByTestId } = render( + + + + ) + const message = getByTestId('default-message') + expect(message).toBeInTheDocument() + }) + + it('should render no data message when the issueDate is invalid', () => { const { getByTestId } = render( @@ -134,26 +169,33 @@ describe('AdvisoryText', () => { expect(message).toBeInTheDocument() }) - it('should only render advisory status if there is only advisory data', () => { + it('should render a no advisories message when there are no advisories/warnings', () => { + const noAdvisoryStore = buildTestStore({ + ...provSummaryInitialState, + fireShapeAreaDetails: noAdvisoryDetails + }) const { queryByTestId } = render( - + ) - const advisoryMessage = queryByTestId('advisory-message-advisory') const warningMessage = queryByTestId('advisory-message-warning') - expect(advisoryMessage).toBeInTheDocument() + const advisoryMessage = queryByTestId('advisory-message-advisory') + const noAdvisoryMessage = queryByTestId('no-advisory-message') + expect(advisoryMessage).not.toBeInTheDocument() expect(warningMessage).not.toBeInTheDocument() + expect(noAdvisoryMessage).toBeInTheDocument() }) - it('should only render warning status if there is only warning data', () => { + it('should render warning status', () => { const warningStore = buildTestStore({ - ...initialState, + ...provSummaryInitialState, fireShapeAreaDetails: warningDetails }) const { queryByTestId } = render( @@ -163,56 +205,141 @@ describe('AdvisoryText', () => { forDate={forDate} advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} + selectedFireZoneUnit={mockFireZoneUnit} /> ) - const warningMessage = queryByTestId('advisory-message-warning') const advisoryMessage = queryByTestId('advisory-message-advisory') + const warningMessage = queryByTestId('advisory-message-warning') expect(advisoryMessage).not.toBeInTheDocument() expect(warningMessage).toBeInTheDocument() }) - it('should render both warning and advisory text if data for both exists', () => { - const warningAdvisoryStore = buildTestStore({ - ...initialState, - fireShapeAreaDetails: warningDetails.concat(advisoryDetails) - }) + it('should render advisory status', () => { const { queryByTestId } = render( - + ) + const advisoryMessage = queryByTestId('advisory-message-advisory') const warningMessage = queryByTestId('advisory-message-warning') + expect(advisoryMessage).toBeInTheDocument() + expect(warningMessage).not.toBeInTheDocument() + }) + + it('should render critical hours missing message when critical hours start time is missing', () => { + const store = buildTestStore( + { + ...provSummaryInitialState, + fireShapeAreaDetails: advisoryDetails + }, + { + ...fuelStatsInitialState, + fireCentreHFIFuelStats: missingCriticalHoursStartFuelStatsState.fireCentreHFIFuelStats + } + ) + const { queryByTestId } = render( + + + + ) const advisoryMessage = queryByTestId('advisory-message-advisory') + const criticalHoursMessage = queryByTestId('advisory-message-no-critical-hours') expect(advisoryMessage).toBeInTheDocument() - expect(warningMessage).toBeInTheDocument() + expect(criticalHoursMessage).toBeInTheDocument() }) - it('should render a no advisories message when there are no advisories/warnings', () => { - const noAdvisoryStore = buildTestStore({ - ...initialState, - fireShapeAreaDetails: noAdvisoryDetails - }) + it('should render critical hours missing message when critical hours end time is missing', () => { + const store = buildTestStore( + { + ...provSummaryInitialState, + fireShapeAreaDetails: advisoryDetails + }, + { + ...fuelStatsInitialState, + fireCentreHFIFuelStats: missingCriticalHoursEndFuelStatsState.fireCentreHFIFuelStats + } + ) const { queryByTestId } = render( - + ) - const warningMessage = queryByTestId('advisory-message-warning') const advisoryMessage = queryByTestId('advisory-message-advisory') - const noAdvisoryMessage = queryByTestId('no-advisory-message') - expect(advisoryMessage).not.toBeInTheDocument() - expect(warningMessage).not.toBeInTheDocument() - expect(noAdvisoryMessage).toBeInTheDocument() + const criticalHoursMessage = queryByTestId('advisory-message-no-critical-hours') + expect(advisoryMessage).toBeInTheDocument() + expect(criticalHoursMessage).toBeInTheDocument() }) }) + +const missingCriticalHoursStartFuelStatsState: FireCentreHFIFuelStatsState = { + error: null, + fireCentreHFIFuelStats: { + 'Prince George Fire Centre': { + '25': [ + { + 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 + } + ] + } + } +} + +const missingCriticalHoursEndFuelStatsState: FireCentreHFIFuelStatsState = { + error: null, + fireCentreHFIFuelStats: { + 'Prince George Fire Centre': { + '25': [ + { + 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 + } + ] + } + } +} diff --git a/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx b/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx index 0c213856e..be47edb95 100644 --- a/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx +++ b/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx @@ -177,6 +177,7 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { forDate={dateOfInterest} advisoryThreshold={ADVISORY_THRESHOLD} selectedFireCenter={fireCenter} + selectedFireZoneUnit={selectedFireShape} />