From 5c0aaae059a56de6773316a7557a23b031861c60 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Tue, 28 Jul 2026 15:53:11 -0700 Subject: [PATCH 01/12] init --- .../fba/components/ActualForecastControl.tsx | 2 +- .../components/actualForecastControl.test.tsx | 2 +- .../features/fba/components/map/FBAMap.tsx | 3 +- .../fba/components/map/fbaMap.test.tsx | 2 +- .../wps-web/src/features/fba/cqlBuilder.ts | 4 +- .../fba/pages/FireBehaviourAdvisoryPage.tsx | 14 ++-- .../src/features/fba/pmtilesBuilder.ts | 4 +- .../fba/slices/fireCentreHFIFuelStatsSlice.ts | 3 +- .../fba/slices/fireCentreTPIStatsSlice.ts | 3 +- .../fba/slices/fireZoneElevationInfoSlice.ts | 4 +- .../fba/slices/provincialSummarySlice.ts | 8 +- .../features/fba/slices/runDatesSlice.test.ts | 11 +-- .../src/features/fba/slices/runDatesSlice.ts | 4 +- .../components/RunTypeDropdown.test.tsx | 24 ++++++ .../components/RunTypeDropdown.tsx | 39 ++++++++++ .../sfmsInsights/components/map/SFMSMap.tsx | 14 +++- .../components/map/layerDefinitions.test.ts | 29 +++++-- .../components/map/layerDefinitions.ts | 21 +++-- .../components/map/sfmsMap.test.tsx | 34 +++++++- .../pages/SFMSInsightsPage.test.tsx | 77 +++++++++++++++---- .../sfmsInsights/pages/SFMSInsightsPage.tsx | 70 ++++++++++++----- .../slices/sfmsInsightsSlice.test.ts | 26 ++++++- .../sfmsInsights/slices/sfmsInsightsSlice.ts | 27 ++++--- web/packages/api/src/fbaAPI.ts | 38 ++------- web/packages/api/src/runType.test.ts | 9 +++ web/packages/api/src/runType.ts | 4 + web/packages/api/src/sfmsAPI.ts | 20 +---- web/packages/api/src/sfmsBounds.ts | 16 ++++ 28 files changed, 366 insertions(+), 146 deletions(-) create mode 100644 web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.test.tsx create mode 100644 web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.tsx create mode 100644 web/packages/api/src/runType.test.ts create mode 100644 web/packages/api/src/runType.ts create mode 100644 web/packages/api/src/sfmsBounds.ts diff --git a/web/apps/wps-web/src/features/fba/components/ActualForecastControl.tsx b/web/apps/wps-web/src/features/fba/components/ActualForecastControl.tsx index 2b5315714a..74e2494d1b 100644 --- a/web/apps/wps-web/src/features/fba/components/ActualForecastControl.tsx +++ b/web/apps/wps-web/src/features/fba/components/ActualForecastControl.tsx @@ -1,5 +1,5 @@ import { FormControl, FormControlLabel, FormLabel, Radio, RadioGroup } from '@mui/material' -import { RunType } from '@wps/api/fbaAPI' +import { RunType } from '@wps/api/runType' import { theme } from '@wps/ui/theme' import { isNull } from 'lodash' import React from 'react' diff --git a/web/apps/wps-web/src/features/fba/components/actualForecastControl.test.tsx b/web/apps/wps-web/src/features/fba/components/actualForecastControl.test.tsx index ecdf8cd305..1568f9a0cd 100644 --- a/web/apps/wps-web/src/features/fba/components/actualForecastControl.test.tsx +++ b/web/apps/wps-web/src/features/fba/components/actualForecastControl.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render } from '@testing-library/react' -import { RunType } from '@wps/api/fbaAPI' +import { RunType } from '@wps/api/runType' import { vi } from 'vitest' import ActualForecastControl from './ActualForecastControl' diff --git a/web/apps/wps-web/src/features/fba/components/map/FBAMap.tsx b/web/apps/wps-web/src/features/fba/components/map/FBAMap.tsx index 00591ce729..06e3b874b3 100644 --- a/web/apps/wps-web/src/features/fba/components/map/FBAMap.tsx +++ b/web/apps/wps-web/src/features/fba/components/map/FBAMap.tsx @@ -1,5 +1,6 @@ import { Box } from '@mui/material' -import { type FireShape, RunType } from '@wps/api/fbaAPI' +import type { FireShape } from '@wps/api/fbaAPI' +import { RunType } from '@wps/api/runType' import type { FireCentre } from '@wps/types/fireCentre' import { ErrorBoundary } from '@wps/ui/ErrorBoundary' import { createHillshadeVectorTileLayer, createVectorTileLayer, getStyleJson } from '@wps/utils/vectorLayerUtils' diff --git a/web/apps/wps-web/src/features/fba/components/map/fbaMap.test.tsx b/web/apps/wps-web/src/features/fba/components/map/fbaMap.test.tsx index b4a180f321..83d9581c92 100644 --- a/web/apps/wps-web/src/features/fba/components/map/fbaMap.test.tsx +++ b/web/apps/wps-web/src/features/fba/components/map/fbaMap.test.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import { RunType } from '@wps/api/fbaAPI' +import { RunType } from '@wps/api/runType' import { createHillshadeVectorTileLayer, createVectorTileLayer, getStyleJson } from '@wps/utils/vectorLayerUtils' import store from 'app/store' import FBAMap from 'features/fba/components/map/FBAMap' diff --git a/web/apps/wps-web/src/features/fba/cqlBuilder.ts b/web/apps/wps-web/src/features/fba/cqlBuilder.ts index 43af099a33..a30a8a7f2b 100644 --- a/web/apps/wps-web/src/features/fba/cqlBuilder.ts +++ b/web/apps/wps-web/src/features/fba/cqlBuilder.ts @@ -1,4 +1,4 @@ -import type { RunType } from '@wps/api/fbaAPI' +import type { RunType } from '@wps/api/runType' import type { DateTime } from 'luxon' /** @@ -17,6 +17,6 @@ import type { DateTime } from 'luxon' */ // TODO: add in param for run_date: DateTime export const buildHFICql = (for_date: DateTime, run_type: RunType) => { - const queryParams = encodeURIComponent(`for_date=${for_date.toISODate()} AND run_type='${run_type.toLowerCase()}'`) + const queryParams = encodeURIComponent(`for_date=${for_date.toISODate()} AND run_type='${run_type}'`) return `filter=${queryParams}` } diff --git a/web/apps/wps-web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx b/web/apps/wps-web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx index b9fb34d788..4d42eebc25 100644 --- a/web/apps/wps-web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx +++ b/web/apps/wps-web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx @@ -1,5 +1,6 @@ import { Box, FormControl, Grid, styled } from '@mui/material' -import { type FireShape, RunType } from '@wps/api/fbaAPI' +import type { FireShape } from '@wps/api/fbaAPI' +import { RunType } from '@wps/api/runType' import { getStations, StationSource } from '@wps/api/stationAPI' import type { FireCentre } from '@wps/types/fireCentre' import AboutDataPopover from '@wps/ui/AboutDataPopover' @@ -52,7 +53,7 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { ? DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`) : DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`).plus({ days: 1 }) ) - const [runType, setRunType] = useState(RunType.FORECAST) + const [runType, setRunType] = useState(RunType.FORECAST) // Set some reasonable historical min and max dates for ASA (used by the DatePicker). const [historicalMinDate, setHistoricalMinDate] = useState( DateTime.fromObject({ year: 2022, month: 4, day: 1 }) @@ -67,11 +68,10 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { const updateDatePickerOptions = useCallback(() => { const dates: DateTime[] = [] - const runTypeLower = runType.toLocaleLowerCase() if (!isNull(sfmsBounds) && !isEmpty(sfmsBounds)) { for (const key of Object.keys(sfmsBounds)) { - const minValue = sfmsBounds[key]?.[runTypeLower]?.minimum - const maxValue = sfmsBounds[key]?.[runTypeLower]?.maximum + const minValue = sfmsBounds[key]?.[runType]?.minimum + const maxValue = sfmsBounds[key]?.[runType]?.maximum if (minValue && maxValue) { const minDate = DateTime.fromISO(minValue) const maxDate = DateTime.fromISO(maxValue) @@ -84,8 +84,8 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { setHistoricalMaxDate(dates[dates.length - 1].plus({ days: 1 })) } } - const currentYearMin = sfmsBounds?.[dateOfInterestYear]?.[runTypeLower]?.minimum ?? `${dateOfInterestYear}-04-01` - const currentYearMax = sfmsBounds?.[dateOfInterestYear]?.[runTypeLower]?.maximum ?? `${dateOfInterestYear}-10-31` + const currentYearMin = sfmsBounds?.[dateOfInterestYear]?.[runType]?.minimum ?? `${dateOfInterestYear}-04-01` + const currentYearMax = sfmsBounds?.[dateOfInterestYear]?.[runType]?.maximum ?? `${dateOfInterestYear}-10-31` setCurrentYearMinDate(DateTime.fromISO(currentYearMin)) setCurrentYearMaxDate(DateTime.fromISO(currentYearMax)) }, [dateOfInterestYear, runType, sfmsBounds]) diff --git a/web/apps/wps-web/src/features/fba/pmtilesBuilder.ts b/web/apps/wps-web/src/features/fba/pmtilesBuilder.ts index 7f94ef554c..cbad3a3ce6 100644 --- a/web/apps/wps-web/src/features/fba/pmtilesBuilder.ts +++ b/web/apps/wps-web/src/features/fba/pmtilesBuilder.ts @@ -1,4 +1,4 @@ -import type { RunType } from '@wps/api/fbaAPI' +import type { RunType } from '@wps/api/runType' import { PMTILES_BUCKET } from '@wps/utils/env' import type { DateTime } from 'luxon' @@ -10,7 +10,7 @@ import type { DateTime } from 'luxon' * @returns a URL to the PMTiles stored in our s3 bucket */ export const buildPMTilesURL = (for_date: DateTime, run_type: RunType, run_date: DateTime): string => { - const PMTilesURL = `${PMTILES_BUCKET}hfi/${run_type.toLowerCase()}/${run_date.toISODate()}/hfi${for_date.toISODate({ + const PMTilesURL = `${PMTILES_BUCKET}hfi/${run_type}/${run_date.toISODate()}/hfi${for_date.toISODate({ format: 'basic' })}.pmtiles` diff --git a/web/apps/wps-web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts b/web/apps/wps-web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts index 1e10f962e1..37c883326a 100644 --- a/web/apps/wps-web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts +++ b/web/apps/wps-web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts @@ -1,5 +1,6 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { type FireCentreHFIStats, getFireCentreHFIStats, type RunType } from '@wps/api/fbaAPI' +import { type FireCentreHFIStats, getFireCentreHFIStats } from '@wps/api/fbaAPI' +import type { RunType } from '@wps/api/runType' import { logError } from '@wps/utils/error' import type { AppThunk } from 'app/store' diff --git a/web/apps/wps-web/src/features/fba/slices/fireCentreTPIStatsSlice.ts b/web/apps/wps-web/src/features/fba/slices/fireCentreTPIStatsSlice.ts index 270c53fcb5..c7ad1eb4bc 100644 --- a/web/apps/wps-web/src/features/fba/slices/fireCentreTPIStatsSlice.ts +++ b/web/apps/wps-web/src/features/fba/slices/fireCentreTPIStatsSlice.ts @@ -1,5 +1,6 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { type FireCentreTPIResponse, getFireCentreTPIStats, type RunType } from '@wps/api/fbaAPI' +import { type FireCentreTPIResponse, getFireCentreTPIStats } from '@wps/api/fbaAPI' +import type { RunType } from '@wps/api/runType' import { logError } from '@wps/utils/error' import type { AppThunk } from 'app/store' diff --git a/web/apps/wps-web/src/features/fba/slices/fireZoneElevationInfoSlice.ts b/web/apps/wps-web/src/features/fba/slices/fireZoneElevationInfoSlice.ts index 6aa4b16745..374b14ecf4 100644 --- a/web/apps/wps-web/src/features/fba/slices/fireZoneElevationInfoSlice.ts +++ b/web/apps/wps-web/src/features/fba/slices/fireZoneElevationInfoSlice.ts @@ -2,9 +2,9 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { type ElevationInfoByThreshold, type FireZoneElevationInfoResponse, - getFireZoneElevationInfo, - type RunType + getFireZoneElevationInfo } from '@wps/api/fbaAPI' +import type { RunType } from '@wps/api/runType' import { logError } from '@wps/utils/error' import type { AppThunk } from 'app/store' diff --git a/web/apps/wps-web/src/features/fba/slices/provincialSummarySlice.ts b/web/apps/wps-web/src/features/fba/slices/provincialSummarySlice.ts index 08ba1f61cb..b00b13161e 100644 --- a/web/apps/wps-web/src/features/fba/slices/provincialSummarySlice.ts +++ b/web/apps/wps-web/src/features/fba/slices/provincialSummarySlice.ts @@ -1,10 +1,6 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { - type FireShapeStatusDetail, - getProvincialSummary, - type ProvincialSummaryResponse, - type RunType -} from '@wps/api/fbaAPI' +import { type FireShapeStatusDetail, getProvincialSummary, type ProvincialSummaryResponse } from '@wps/api/fbaAPI' +import type { RunType } from '@wps/api/runType' import { logError } from '@wps/utils/error' import type { RootState } from 'app/rootReducer' import type { AppThunk } from 'app/store' diff --git a/web/apps/wps-web/src/features/fba/slices/runDatesSlice.test.ts b/web/apps/wps-web/src/features/fba/slices/runDatesSlice.test.ts index e6f9057e14..4c9402b308 100644 --- a/web/apps/wps-web/src/features/fba/slices/runDatesSlice.test.ts +++ b/web/apps/wps-web/src/features/fba/slices/runDatesSlice.test.ts @@ -1,12 +1,7 @@ import { combineReducers } from '@reduxjs/toolkit' -import { - getAllRunDates, - getMostRecentRunDate, - getSFMSBounds, - RunType, - type SFMSBounds, - type SFMSBoundsResponse -} from '@wps/api/fbaAPI' +import { getAllRunDates, getMostRecentRunDate, getSFMSBounds } from '@wps/api/fbaAPI' +import { RunType } from '@wps/api/runType' +import type { SFMSBounds, SFMSBoundsResponse } from '@wps/api/sfmsBounds' import { logError } from '@wps/utils/error' import { DateTime } from 'luxon' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/web/apps/wps-web/src/features/fba/slices/runDatesSlice.ts b/web/apps/wps-web/src/features/fba/slices/runDatesSlice.ts index 3667f93584..d9db8b97ee 100644 --- a/web/apps/wps-web/src/features/fba/slices/runDatesSlice.ts +++ b/web/apps/wps-web/src/features/fba/slices/runDatesSlice.ts @@ -1,5 +1,7 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { getAllRunDates, getMostRecentRunDate, getSFMSBounds, type RunType, type SFMSBounds } from '@wps/api/fbaAPI' +import { getAllRunDates, getMostRecentRunDate, getSFMSBounds } from '@wps/api/fbaAPI' +import type { RunType } from '@wps/api/runType' +import type { SFMSBounds } from '@wps/api/sfmsBounds' import { logError } from '@wps/utils/error' import type { AppThunk } from 'app/store' import type { DateTime } from 'luxon' diff --git a/web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.test.tsx b/web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.test.tsx new file mode 100644 index 0000000000..a78be0f6ea --- /dev/null +++ b/web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.test.tsx @@ -0,0 +1,24 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { RunType } from '@wps/api/runType' +import RunTypeDropdown from './RunTypeDropdown' + +describe('RunTypeDropdown', () => { + it('shows actuals and forecasts', () => { + render() + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Source' })) + + expect(screen.getByRole('option', { name: 'Actual' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Forecast' })).toBeInTheDocument() + }) + + it('updates the selected run type', () => { + const setSelectedRunType = vi.fn() + render() + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Source' })) + fireEvent.click(screen.getByRole('option', { name: 'Forecast' })) + + expect(setSelectedRunType).toHaveBeenCalledWith(RunType.FORECAST) + }) +}) diff --git a/web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.tsx b/web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.tsx new file mode 100644 index 0000000000..4d7bbe6601 --- /dev/null +++ b/web/apps/wps-web/src/features/sfmsInsights/components/RunTypeDropdown.tsx @@ -0,0 +1,39 @@ +import { FormControl, InputLabel, MenuItem, Select, type SelectChangeEvent } from '@mui/material' +import { RunType } from '@wps/api/runType' + +const RUN_TYPE_LABELS: Record = { + [RunType.ACTUAL]: 'Actual', + [RunType.FORECAST]: 'Forecast' +} + +interface RunTypeDropdownProps { + selectedRunType: RunType + setSelectedRunType: (runType: RunType) => void +} + +const RunTypeDropdown = ({ selectedRunType, setSelectedRunType }: RunTypeDropdownProps) => { + const handleChange = (event: SelectChangeEvent) => { + setSelectedRunType(event.target.value as RunType) + } + + return ( + + Source + + + ) +} + +export default RunTypeDropdown diff --git a/web/apps/wps-web/src/features/sfmsInsights/components/map/SFMSMap.tsx b/web/apps/wps-web/src/features/sfmsInsights/components/map/SFMSMap.tsx index 9e32a94bfb..0d0a7716f4 100644 --- a/web/apps/wps-web/src/features/sfmsInsights/components/map/SFMSMap.tsx +++ b/web/apps/wps-web/src/features/sfmsInsights/components/map/SFMSMap.tsx @@ -1,5 +1,6 @@ import { Box, CircularProgress } from '@mui/material' import { ErrorBoundary } from '@sentry/react' +import { RunType } from '@wps/api/runType' import { BC_EXTENT, CENTER_OF_BC } from '@wps/utils/constants' import { BASEMAP_STYLE_URL, BASEMAP_TILE_URL } from '@wps/utils/env' import { createVectorTileLayer, getStyleJson } from '@wps/utils/vectorLayerUtils' @@ -36,10 +37,17 @@ interface SFMSMapProps { snowDate: DateTime | null rasterDate: DateTime | null rasterType?: RasterType + runType?: RunType showSnow?: boolean } -const SFMSMap = ({ snowDate, rasterDate, rasterType = 'fwi', showSnow = true }: SFMSMapProps) => { +const SFMSMap = ({ + snowDate, + rasterDate, + rasterType = 'fwi', + runType = RunType.ACTUAL, + showSnow = true +}: SFMSMapProps) => { const token = useSelector(selectToken) const [map, setMap] = useState(null) const [rasterTooltipData, setRasterTooltipData] = useState({ @@ -114,11 +122,11 @@ const SFMSMap = ({ snowDate, rasterDate, rasterType = 'fwi', showSnow = true }: // Only load raster layer if we have a date (for fire weather) or if type is fuel (date-independent) if (rasterDate || rasterType === 'fuel') { - rasterLayerManagerRef.current.updateLayer(getRasterLayer(rasterDate, rasterType, token)) + rasterLayerManagerRef.current.updateLayer(getRasterLayer(rasterDate, rasterType, token, runType)) } else { rasterLayerManagerRef.current.updateLayer(null) } - }, [rasterDate, rasterType, token]) + }, [rasterDate, rasterType, runType, token]) return ( diff --git a/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.test.ts b/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.test.ts index 728debc1f2..1747e526e8 100644 --- a/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.test.ts +++ b/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.test.ts @@ -1,10 +1,6 @@ +import { RunType } from '@wps/api/runType' import { DateTime } from 'luxon' -import { - getFireWeatherRasterLayer, - getRasterLayer, - getSFMSNGActualRasterPath, - getSnowPMTilesLayer -} from './layerDefinitions' +import { getFireWeatherRasterLayer, getRasterLayer, getSFMSNGRasterPath, getSnowPMTilesLayer } from './layerDefinitions' type Listener = (...args: unknown[]) => void @@ -113,17 +109,25 @@ describe('layerDefinitions', () => { it('should generate SFMSNG actual COG paths for FWI rasters', () => { const rasterDate = DateTime.fromISO('2025-11-02') - expect(getSFMSNGActualRasterPath(rasterDate, 'fwi')).toBe('sfms_ng/actual/2025/11/02/fwi_20251102_cog.tif') + expect(getSFMSNGRasterPath(rasterDate, 'fwi')).toBe('sfms_ng/actual/2025/11/02/fwi_20251102_cog.tif') }) it('should generate SFMSNG actual COG paths for weather rasters', () => { const rasterDate = DateTime.fromISO('2025-11-02') - expect(getSFMSNGActualRasterPath(rasterDate, 'relative_humidity')).toBe( + expect(getSFMSNGRasterPath(rasterDate, 'relative_humidity')).toBe( 'sfms_ng/actual/2025/11/02/relative_humidity_20251102_cog.tif' ) }) + it('should generate SFMSNG forecast COG paths', () => { + const rasterDate = DateTime.fromISO('2025-11-05') + + expect(getSFMSNGRasterPath(rasterDate, 'fwi', RunType.FORECAST)).toBe( + 'sfms_ng/forecast/2025/11/05/fwi_20251105_cog.tif' + ) + }) + it('should create fire weather layer with zIndex 52', () => { const rasterDate = DateTime.fromISO('2025-11-02') const layer = getFireWeatherRasterLayer(rasterDate, 'fwi', 'test-token') @@ -192,6 +196,15 @@ describe('layerDefinitions', () => { expect(layer!.getProperties().rasterType).toBe('temperature') }) + it('should return a forecast fire weather layer when forecast data is selected', () => { + const date = DateTime.fromISO('2025-11-05') + const layer = getRasterLayer(date, 'fwi', 'test-token', RunType.FORECAST) + + expect(layer).toBeDefined() + expect(layer).not.toBeNull() + expect(layer!.getProperties().rasterType).toBe('fwi') + }) + it('should return null and log error when date is null for fire weather raster', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const layer = getRasterLayer(null, 'fwi', 'test-token') diff --git a/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.ts b/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.ts index 8e7301e2db..2db016dcc0 100644 --- a/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.ts +++ b/web/apps/wps-web/src/features/sfmsInsights/components/map/layerDefinitions.ts @@ -1,3 +1,4 @@ +import { RunType } from '@wps/api/runType' import { BC_EXTENT } from '@wps/utils/constants' import { API_BASE_URL } from '@wps/utils/env' import type { DateTime } from 'luxon' @@ -19,11 +20,15 @@ export const BASEMAP_LAYER_NAME = 'basemapLayer' export const SNOW_LAYER_NAME = 'snowVector' export const FWI_LAYER_NAME = 'fwiRaster' -export const getSFMSNGActualRasterPath = (date: DateTime, rasterType: SFMSNGRasterType) => { +export const getSFMSNGRasterPath = ( + date: DateTime, + rasterType: SFMSNGRasterType, + runType: RunType = RunType.ACTUAL +) => { const dateString = date.toISODate() ?? '' const datePath = dateString.replaceAll('-', '/') const dateStringBasic = dateString.replaceAll('-', '') - return `sfms_ng/actual/${datePath}/${rasterType}_${dateStringBasic}_cog.tif` + return `sfms_ng/${runType}/${datePath}/${rasterType}_${dateStringBasic}_cog.tif` } export const getSnowPMTilesLayer = (snowDate: DateTime, token?: string) => { @@ -59,9 +64,10 @@ export const getFireWeatherRasterLayer = ( date: DateTime, rasterType: SFMSNGRasterType, token: string | undefined, + runType: RunType = RunType.ACTUAL, layerName: string = FWI_LAYER_NAME ) => { - const path = getSFMSNGActualRasterPath(date, rasterType) + const path = getSFMSNGRasterPath(date, rasterType, runType) const url = `${API_BASE_URL}/object-store-proxy/${path}` // Prepare headers for authentication @@ -136,7 +142,12 @@ export const getFuelCOGTiles = (token?: string) => { * Get the appropriate raster layer based on type * Handles both fire weather rasters (date-dependent) and fuel raster (static) */ -export const getRasterLayer = (date: DateTime | null, rasterType: RasterType, token: string | undefined) => { +export const getRasterLayer = ( + date: DateTime | null, + rasterType: RasterType, + token: string | undefined, + runType: RunType = RunType.ACTUAL +) => { if (rasterType === 'fuel') { return getFuelCOGTiles(token) } @@ -144,5 +155,5 @@ export const getRasterLayer = (date: DateTime | null, rasterType: RasterType, to console.error('date is required for fire weather rasters') return null } - return getFireWeatherRasterLayer(date, rasterType, token) + return getFireWeatherRasterLayer(date, rasterType, token, runType) } diff --git a/web/apps/wps-web/src/features/sfmsInsights/components/map/sfmsMap.test.tsx b/web/apps/wps-web/src/features/sfmsInsights/components/map/sfmsMap.test.tsx index 05d8abc527..67e6681a08 100644 --- a/web/apps/wps-web/src/features/sfmsInsights/components/map/sfmsMap.test.tsx +++ b/web/apps/wps-web/src/features/sfmsInsights/components/map/sfmsMap.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react' +import { RunType } from '@wps/api/runType' import { createVectorTileLayer, getStyleJson } from '@wps/utils/vectorLayerUtils' import { DateTime } from 'luxon' import { Provider } from 'react-redux' @@ -148,7 +149,8 @@ describe('SFMSMap', () => { expect(layerDefinitions.getRasterLayer).toHaveBeenLastCalledWith( DateTime.fromISO('2025-11-03'), 'fwi', - 'test-token' + 'test-token', + 'actual' ) }) @@ -159,7 +161,7 @@ describe('SFMSMap', () => { it('should request raster layer for fuel type even when rasterDate is null', () => { renderWithStore() - expect(layerDefinitions.getRasterLayer).toHaveBeenCalledWith(null, 'fuel', 'test-token') + expect(layerDefinitions.getRasterLayer).toHaveBeenCalledWith(null, 'fuel', 'test-token', 'actual') }) it('should request new layer when rasterType changes', () => { @@ -179,10 +181,29 @@ describe('SFMSMap', () => { expect(layerDefinitions.getRasterLayer).toHaveBeenLastCalledWith( DateTime.fromISO('2025-11-02'), 'ffmc', - 'test-token' + 'test-token', + 'actual' ) }) + it('should request a new layer when runType changes', () => { + const store = makeStore() + const rasterDate = DateTime.fromISO('2025-11-02') + const { rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(layerDefinitions.getRasterLayer).toHaveBeenLastCalledWith(rasterDate, 'fwi', 'test-token', RunType.FORECAST) + }) + it('should clear error when rasterDate changes', () => { const store = makeStore() const { rerender } = render( @@ -242,7 +263,12 @@ describe('SFMSMap', () => { ) - expect(layerDefinitions.getRasterLayer).toHaveBeenLastCalledWith(DateTime.fromISO('2025-11-02'), 'fwi', 'new-token') + expect(layerDefinitions.getRasterLayer).toHaveBeenLastCalledWith( + DateTime.fromISO('2025-11-02'), + 'fwi', + 'new-token', + 'actual' + ) }) it('should update snow layer when snowDate changes', () => { diff --git a/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx b/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx index 1c26bb8e7d..1f2231cad5 100644 --- a/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx +++ b/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx @@ -21,12 +21,14 @@ vi.mock('@/features/sfmsInsights/components/map/SFMSMap', () => { showSnow, snowDate, rasterDate, - rasterType + rasterType, + runType }: { showSnow: boolean snowDate: DateTime | null rasterDate: DateTime | null rasterType: string + runType: string }) => (
{ data-snow-date={snowDate?.toISO() ?? 'null'} data-raster-date={rasterDate?.toISO() ?? 'null'} data-raster-type={rasterType} + data-run-type={runType} > Mock SFMS Map
@@ -136,6 +139,10 @@ describe('SFMSInsightsPage', () => { actual: { minimum: '2025-01-01', maximum: '2025-11-02' + }, + forecast: { + minimum: '2025-11-03', + maximum: '2025-11-05' } } } @@ -194,6 +201,51 @@ describe('SFMSInsightsPage', () => { expect(map).toHaveAttribute('data-raster-type', 'fuel') }) + it('should show actuals by default', async () => { + renderWithStore() + await waitForPageLoad() + + expect(screen.getByRole('combobox', { name: 'Source' })).toHaveTextContent('Actual') + expect(screen.getByTestId('sfms-map')).toHaveAttribute('data-run-type', 'actual') + }) + + it('should switch to the latest forecast date and forecast raster data', async () => { + renderWithStore() + await waitForPageLoad() + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Source' })) + fireEvent.click(screen.getByRole('option', { name: 'Forecast' })) + + await waitFor(() => { + expect(screen.getByTestId('current-date')).toHaveTextContent('2025-11-05') + expect(screen.getByTestId('historical-min-date')).toHaveTextContent('2025-11-03') + expect(screen.getByTestId('historical-max-date')).toHaveTextContent('2025-11-05') + expect(screen.getByTestId('sfms-map')).toHaveAttribute('data-run-type', 'forecast') + }) + }) + + it('should disable dated raster controls when forecasts are unavailable', async () => { + renderWithStore({ + '2025': { + actual: { + minimum: '2025-01-01', + maximum: '2025-11-02' + } + } + }) + await waitForPageLoad() + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Source' })) + fireEvent.click(screen.getByRole('option', { name: 'Forecast' })) + + await waitFor(() => { + expect(screen.getByTestId('current-date')).toHaveTextContent('null') + expect(screen.getByTestId('date-picker')).toHaveAttribute('data-disabled', 'true') + expect(screen.getByTestId('raster-type-dropdown')).toHaveAttribute('data-raster-data-available', 'false') + expect(screen.getByTestId('sfms-map')).toHaveAttribute('data-run-type', 'forecast') + }) + }) + it('should set date picker max date based on SFMS bounds', async () => { renderWithStore() await waitForPageLoad() @@ -385,7 +437,7 @@ describe('SFMSInsightsPage', () => { expect(minDate.textContent).toBe('2025-05-01') }) - it('should set rasterDate to today when latestBounds is null', async () => { + it('should disable date selection when SFMS bounds are unavailable', async () => { renderWithStore(null) // Wait for fetch to complete @@ -394,8 +446,8 @@ describe('SFMSInsightsPage', () => { }) const currentDate = screen.getByTestId('current-date') - // Should default to today's date (mocked as 2025-11-02) - expect(currentDate.textContent).toBe('2025-11-02') + expect(currentDate.textContent).toBe('null') + expect(screen.getByTestId('date-picker')).toHaveAttribute('data-disabled', 'true') // Min/max dates should use default values const minDate = screen.getByTestId('historical-min-date') @@ -404,7 +456,7 @@ describe('SFMSInsightsPage', () => { expect(maxDate.textContent).toBe('2025-11-12') }) - it('should set rasterDate to today when latestBounds.maximum is empty', async () => { + it('should disable date selection when latestBounds.maximum is empty', async () => { renderWithStore({ '2025': { actual: { @@ -419,15 +471,15 @@ describe('SFMSInsightsPage', () => { expect(datePicker).toBeInTheDocument() const currentDate = screen.getByTestId('current-date') - // Should default to today's date (mocked as 2025-11-02) - expect(currentDate.textContent).toBe('2025-11-02') + expect(currentDate.textContent).toBe('null') + expect(datePicker).toHaveAttribute('data-disabled', 'true') // minDate should be set from bounds const minDate = screen.getByTestId('historical-min-date') expect(minDate.textContent).toBe('2025-05-01') }) - it('should not set minDate when earliestBounds.minimum is empty', async () => { + it('should limit minDate to the latest date when earliestBounds.minimum is empty', async () => { renderWithStore({ '2025': { actual: { @@ -440,11 +492,10 @@ describe('SFMSInsightsPage', () => { const minDate = screen.getByTestId('historical-min-date') - // minDate should use default value since earliestBounds.minimum is empty - expect(minDate.textContent).toBe('2025-01-01') + expect(minDate.textContent).toBe('2025-10-15') }) - it('should set rasterDate to today when all years have empty maximum', async () => { + it('should disable date selection when all years have empty maximum', async () => { renderWithStore({ '2024': { actual: { @@ -465,8 +516,8 @@ describe('SFMSInsightsPage', () => { expect(datePicker).toBeInTheDocument() const currentDate = screen.getByTestId('current-date') - // Should default to today's date (mocked as 2025-11-02) - expect(currentDate.textContent).toBe('2025-11-02') + expect(currentDate.textContent).toBe('null') + expect(datePicker).toHaveAttribute('data-disabled', 'true') // minDate should be set from 2024 bounds const minDate = screen.getByTestId('historical-min-date') diff --git a/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx b/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx index 807d98a4ef..a5a29fcf44 100644 --- a/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx +++ b/web/apps/wps-web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx @@ -1,4 +1,5 @@ import { Box, Checkbox, CircularProgress, FormControlLabel, Grid } from '@mui/material' +import { RunType } from '@wps/api/runType' import { getMostRecentProcessedSnowByDate } from '@wps/api/snow' import AboutDataPopover from '@wps/ui/AboutDataPopover' import { GeneralHeader } from '@wps/ui/GeneralHeader' @@ -9,12 +10,14 @@ import { isNull } from 'lodash' import { DateTime } from 'luxon' import { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' +import type { RootState } from '@/app/rootReducer' import type { AppDispatch } from '@/app/store' import ASADatePicker from '@/features/fba/components/ASADatePicker' import Footer from '@/features/landingPage/components/Footer' import type { RasterType } from '@/features/sfmsInsights/components/map/rasterConfig' import SFMSMap from '@/features/sfmsInsights/components/map/SFMSMap' import RasterTypeDropdown from '@/features/sfmsInsights/components/RasterTypeDropdown' +import RunTypeDropdown from '@/features/sfmsInsights/components/RunTypeDropdown' import { SfmsInsightsAboutDataContent } from '@/features/sfmsInsights/components/SfmsInsightsAboutDataContent' import { fetchSFMSInsightsBounds, @@ -26,20 +29,28 @@ import { export const SFMSInsightsPage = () => { const dispatch = useDispatch() - const sfmsBounds = useSelector(selectSFMSInsightsBounds) - const sfmsBoundsLoading = useSelector(selectSFMSInsightsBoundsLoading) - const latestBounds = useSelector(selectLatestSFMSInsightsBounds) - const earliestBounds = useSelector(selectEarliestSFMSInsightsBounds) + + // state + const [runType, setRunType] = useState(RunType.ACTUAL) + const [rasterType, setRasterType] = useState('fuel') + const [rasterDate, setRasterDate] = useState(null) const [snowDate, setSnowDate] = useState(null) - const [rasterDate, setRasterDate] = useState(getDateTimeNowPST()) - const [maxDate, setMaxDate] = useState(getDateTimeNowPST().plus({ days: 10 })) + const [showSnow, setShowSnow] = useState(true) const [minDate, setMinDate] = useState( DateTime.fromObject({ day: 1, month: 1, year: getDateTimeNowPST().year }) ) + const [maxDate, setMaxDate] = useState(getDateTimeNowPST().plus({ days: 10 })) - const [rasterType, setRasterType] = useState('fuel') - const [showSnow, setShowSnow] = useState(true) + // selectors + const sfmsBounds = useSelector(selectSFMSInsightsBounds) + const sfmsBoundsLoading = useSelector(selectSFMSInsightsBoundsLoading) + const latestBounds = useSelector((state: RootState) => selectLatestSFMSInsightsBounds(state, runType)) + const earliestBounds = useSelector((state: RootState) => selectEarliestSFMSInsightsBounds(state, runType)) + + // derived values + const rasterDataAvailable = !!latestBounds?.maximum + // effects useEffect(() => { if (sfmsBounds !== undefined || sfmsBoundsLoading) { return @@ -49,18 +60,23 @@ export const SFMSInsightsPage = () => { }, [dispatch, sfmsBounds, sfmsBoundsLoading]) useEffect(() => { - if (earliestBounds?.minimum) { - setMinDate(DateTime.fromISO(earliestBounds.minimum)) + if (!latestBounds?.maximum) { + setRasterDate(null) + return } - }, [earliestBounds]) + + const latestDate = DateTime.fromISO(latestBounds.maximum) + setMaxDate(latestDate) + setRasterDate(latestDate) + }, [latestBounds]) useEffect(() => { - if (latestBounds?.maximum) { - const latestDate = DateTime.fromISO(latestBounds.maximum) - setMaxDate(latestDate) - setRasterDate(currentDate => (currentDate?.toISODate() === latestDate.toISODate() ? currentDate : latestDate)) + if (earliestBounds?.minimum) { + setMinDate(DateTime.fromISO(earliestBounds.minimum)) + } else if (latestBounds?.maximum) { + setMinDate(DateTime.fromISO(latestBounds.maximum)) } - }, [latestBounds]) + }, [earliestBounds, latestBounds]) useEffect(() => { // Only fetch snow data once rasterDate is set @@ -82,6 +98,12 @@ export const SFMSInsightsPage = () => { fetchLastProcessedSnow(rasterDate) }, [rasterDate]) + // handlers + const updateRunType = (newRunType: RunType) => { + setRasterDate(null) + setRunType(newRunType) + } + return ( @@ -113,6 +135,7 @@ export const SFMSInsightsPage = () => { { )} + + + + + @@ -147,7 +175,13 @@ export const SFMSInsightsPage = () => { - +