From 1a8a148d6bdbc4d3d1097b7972217c33297c49c7 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Fri, 16 Feb 2024 11:33:03 -0800 Subject: [PATCH] MoreCast: Forecast - Actual split (#3415) -splits out forecast and actual columns -removes N/A for forecast-able cells -disables cells for previous dates -No 'Actual' label in Actual column closes #3394 --- .../moreCast2/components/ColumnDefBuilder.tsx | 34 +++-- .../components/GridComponentRenderer.tsx | 98 ++++++++++---- .../moreCast2/components/MoreCast2Column.tsx | 20 ++- .../moreCast2/components/TabbedDataGrid.tsx | 12 +- .../components/colDefBuilder.test.tsx | 34 ++++- .../components/gridComponentRenderer.test.tsx | 128 +++++++++++++++--- web/src/features/moreCast2/util.ts | 11 ++ 7 files changed, 266 insertions(+), 71 deletions(-) diff --git a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx index 55e7097e9..0581c7b2f 100644 --- a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx +++ b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx @@ -14,6 +14,7 @@ export const DEFAULT_FORECAST_COLUMN_WIDTH = 120 // Defines the order in which weather models display in the datagrid. export const ORDERED_COLUMN_HEADERS: WeatherDeterminateType[] = [ + WeatherDeterminate.ACTUAL, WeatherDeterminate.HRDPS, WeatherDeterminate.HRDPS_BIAS, WeatherDeterminate.RDPS, @@ -26,6 +27,14 @@ export const ORDERED_COLUMN_HEADERS: WeatherDeterminateType[] = [ WeatherDeterminate.GFS_BIAS ] +// Columns that can have values entered as part of a forecast +export const TEMP_HEADER = 'Temp' +export const RH_HEADER = 'RH' +export const WIND_SPEED_HEADER = 'Wind Speed' +export const WIND_DIR_HEADER = 'Wind Dir' +export const PRECIP_HEADER = 'Precip' +export const GC_HEADER = 'GC' + export interface ForecastColDefGenerator { generateForecastColDef: (headerName?: string) => GridColDef } @@ -51,7 +60,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato public generateForecastColDef = (headerName?: string) => { return this.generateForecastColDefWith( `${this.field}${WeatherDeterminate.FORECAST}`, - headerName ? headerName : this.headerName, + headerName ?? this.headerName, this.precision, DEFAULT_FORECAST_COLUMN_WIDTH ) @@ -75,7 +84,12 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato ? ORDERED_COLUMN_HEADERS : ORDERED_COLUMN_HEADERS.filter(header => !header.endsWith('_BIAS')) return fields.map(header => - this.generateColDefWith(`${this.field}${header}`, header, this.precision, DEFAULT_COLUMN_WIDTH) + this.generateColDefWith( + `${this.field}${header}`, + header, + this.precision, + header.includes('Actual') ? DEFAULT_FORECAST_COLUMN_WIDTH : DEFAULT_COLUMN_WIDTH + ) ) } @@ -87,13 +101,15 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato headerName, sortable: false, type: 'number', - width: width ? width : DEFAULT_COLUMN_WIDTH, + width: width ?? DEFAULT_COLUMN_WIDTH, renderCell: (params: Pick) => { return this.gridComponentRenderer.renderCellWith(params) }, renderHeader: (params: GridColumnHeaderParams) => { return this.gridComponentRenderer.renderHeaderWith(params) }, + valueGetter: (params: Pick) => + this.gridComponentRenderer.valueGetter(params, precision, field, headerName), valueFormatter: (params: Pick) => { return this.valueFormatterWith(params, precision) } @@ -129,7 +145,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato return this.valueFormatterWith(params, precision) }, valueGetter: (params: Pick) => - this.gridComponentRenderer.valueGetter(params, precision, field), + this.gridComponentRenderer.valueGetter(params, precision, field, headerName), valueSetter: (params: Pick) => this.valueSetterWith(params, field, precision) } @@ -137,10 +153,12 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato public valueFormatterWith = (params: Pick, precision: number) => this.gridComponentRenderer.predictionItemValueFormatter(params, precision) - public valueGetterWith = (params: Pick, precision: number) => - this.gridComponentRenderer.cellValueGetter(params, precision) - public valueGetter = (params: Pick, field: string, precision: number) => - this.gridComponentRenderer.valueGetter(params, precision, field) + public valueGetter = ( + params: Pick, + field: string, + precision: number, + headerName: string + ) => this.gridComponentRenderer.valueGetter(params, precision, field, headerName) public valueSetterWith = (params: Pick, field: string, precision: number) => this.gridComponentRenderer.predictionItemValueSetter(params, field, precision) } diff --git a/web/src/features/moreCast2/components/GridComponentRenderer.tsx b/web/src/features/moreCast2/components/GridComponentRenderer.tsx index e734de4b3..dd3f7b0bd 100644 --- a/web/src/features/moreCast2/components/GridComponentRenderer.tsx +++ b/web/src/features/moreCast2/components/GridComponentRenderer.tsx @@ -7,10 +7,20 @@ import { GridValueGetterParams, GridValueSetterParams } from '@mui/x-data-grid' -import { ModelChoice } from 'api/moreCast2API' -import { createLabel } from 'features/moreCast2/util' +import { ModelChoice, WeatherDeterminate } from 'api/moreCast2API' +import { createWeatherModelLabel, isPreviousToToday } from 'features/moreCast2/util' +import { MoreCast2Row } from 'features/moreCast2/interfaces' +import { + GC_HEADER, + PRECIP_HEADER, + RH_HEADER, + TEMP_HEADER, + WIND_DIR_HEADER, + WIND_SPEED_HEADER +} from 'features/moreCast2/components/ColumnDefBuilder' -const NOT_AVAILABLE = 'N/A' +export const NOT_AVAILABLE = 'N/A' +export const NOT_REPORTING = 'N/R' export class GridComponentRenderer { public renderForecastHeaderWith = (params: GridColumnHeaderParams) => { @@ -39,40 +49,66 @@ export class GridComponentRenderer { return actualField } + public rowContainsActual = (row: MoreCast2Row): boolean => { + for (const key in row) { + if (key.includes(WeatherDeterminate.ACTUAL)) { + const value = row[key as keyof MoreCast2Row] + if (typeof value === 'number' && !isNaN(value)) { + return true + } + } + } + return false + } + public valueGetter = ( params: Pick, precision: number, - field: string + field: string, + headerName: string ): string => { - const actualField = this.getActualField(field) - const actual = params.row[actualField] - - if (!isNaN(actual)) { - return Number(actual).toFixed(precision) + // The grass curing column is the only column that shows both actuals and forecast in a single column + if (field.includes('grass')) { + const actualField = this.getActualField(field) + const actual = params.row[actualField] + + if (!isNaN(actual)) { + return Number(actual).toFixed(precision) + } } - const value = params?.value?.value + const value = params?.value?.value ?? params.value + // The 'Actual' column will show N/R for Not Reporting, instead of N/A + const noDataField = headerName === WeatherDeterminate.ACTUAL ? NOT_REPORTING : NOT_AVAILABLE - if (isNaN(value)) { - return 'NaN' - } - return Number(value).toFixed(precision) + const isPreviousDate = isPreviousToToday(params.row['forDate']) + const isForecastColumn = this.isForecastColumn(headerName) + const rowContainsActual = this.rowContainsActual(params.row) + + // If a cell has no value, belongs to a Forecast column, is a future forDate, and the row doesn't contain any Actuals from today, + // we can leave it blank, so it's obvious that it can have a value entered into it. + if (isNaN(value) && !isPreviousDate && isForecastColumn && !rowContainsActual) { + return '' + } else return isNaN(value) ? noDataField : value.toFixed(precision) } public renderForecastCellWith = (params: Pick, field: string) => { - // The value of field will be precipForecast, rhForecast, tempForecast, etc. - // We need the prefix to help us grab the correct 'actual' field (eg. tempACTUAL, precipACTUAL, etc.) - const actualField = this.getActualField(field) + // If a single cell in a row contains an Actual, no Forecast will be entered into the row anymore, so we can disable the whole row. + const isActual = this.rowContainsActual(params.row) + // We can disable a cell if an Actual exists or the forDate is before today. + // Both forDate and today are currently in the system's time zone + const isPreviousDate = isPreviousToToday(params.row['forDate']) const isGrassField = field.includes('grass') - const isActual = !isNaN(params.row[actualField]) - return ( ) @@ -98,13 +134,23 @@ export class GridComponentRenderer { return { ...params.row } } + public isForecastColumn = (headerName: string): boolean => { + const forecastColumns = [ + WeatherDeterminate.FORECAST, + TEMP_HEADER, + RH_HEADER, + WIND_DIR_HEADER, + WIND_SPEED_HEADER, + PRECIP_HEADER, + GC_HEADER + ] + + return forecastColumns.some(column => column === headerName) + } + public predictionItemValueFormatter = (params: Pick, precision: number) => { const value = Number.parseFloat(params?.value) - return isNaN(value) ? NOT_AVAILABLE : value.toFixed(precision) - } - - public cellValueGetter = (params: Pick, precision: number) => { - return isNaN(params?.value) ? 'NaN' : params.value.toFixed(precision) + return isNaN(value) ? params.value : value.toFixed(precision) } } diff --git a/web/src/features/moreCast2/components/MoreCast2Column.tsx b/web/src/features/moreCast2/components/MoreCast2Column.tsx index c6af31faf..8f866f72d 100644 --- a/web/src/features/moreCast2/components/MoreCast2Column.tsx +++ b/web/src/features/moreCast2/components/MoreCast2Column.tsx @@ -3,7 +3,13 @@ import { DateTime } from 'luxon' import { ColDefGenerator, ColumnDefBuilder, - ForecastColDefGenerator + ForecastColDefGenerator, + GC_HEADER, + PRECIP_HEADER, + RH_HEADER, + TEMP_HEADER, + WIND_DIR_HEADER, + WIND_SPEED_HEADER } from 'features/moreCast2/components/ColumnDefBuilder' import { GridComponentRenderer } from 'features/moreCast2/components/GridComponentRenderer' @@ -107,11 +113,11 @@ export class IndeterminateField implements ColDefGenerator, ForecastColDefGenera } } -export const tempForecastField = new IndeterminateField('temp', 'Temp', 'number', 1, true) -export const rhForecastField = new IndeterminateField('rh', 'RH', 'number', 0, true) -export const windDirForecastField = new IndeterminateField('windDirection', 'Wind Dir', 'number', 0, true) -export const windSpeedForecastField = new IndeterminateField('windSpeed', 'Wind Speed', 'number', 1, true) -export const precipForecastField = new IndeterminateField('precip', 'Precip', 'number', 1, true) +export const tempForecastField = new IndeterminateField('temp', TEMP_HEADER, 'number', 1, true) +export const rhForecastField = new IndeterminateField('rh', RH_HEADER, 'number', 0, true) +export const windDirForecastField = new IndeterminateField('windDirection', WIND_DIR_HEADER, 'number', 0, true) +export const windSpeedForecastField = new IndeterminateField('windSpeed', WIND_SPEED_HEADER, 'number', 1, true) +export const precipForecastField = new IndeterminateField('precip', PRECIP_HEADER, 'number', 1, true) export const buiField = new IndeterminateField('buiCalc', 'BUI', 'number', 0, false) export const isiField = new IndeterminateField('isiCalc', 'ISI', 'number', 1, false) export const fwiField = new IndeterminateField('fwiCalc', 'FWI', 'number', 0, false) @@ -119,7 +125,7 @@ export const ffmcField = new IndeterminateField('ffmcCalc', 'FFMC', 'number', 1, export const dmcField = new IndeterminateField('dmcCalc', 'DMC', 'number', 0, false) export const dcField = new IndeterminateField('dcCalc', 'DC', 'number', 0, false) export const dgrField = new IndeterminateField('dgrCalc', 'DGR', 'number', 0, false) -export const gcField = new IndeterminateField('grassCuring', 'GC', 'number', 0, false) +export const gcField = new IndeterminateField('grassCuring', GC_HEADER, 'number', 0, false) export const MORECAST2_STATION_DATE_FIELDS: ColDefGenerator[] = [ StationForecastField.getInstance(), diff --git a/web/src/features/moreCast2/components/TabbedDataGrid.tsx b/web/src/features/moreCast2/components/TabbedDataGrid.tsx index 7fc0cdbb6..59854cf77 100644 --- a/web/src/features/moreCast2/components/TabbedDataGrid.tsx +++ b/web/src/features/moreCast2/components/TabbedDataGrid.tsx @@ -7,7 +7,13 @@ import { GridColumnVisibilityModel, GridEventListener } from '@mui/x-data-grid' -import { ModelChoice, ModelType, submitMoreCastForecastRecords } from 'api/moreCast2API' +import { + ModelChoice, + ModelType, + WeatherDeterminate, + WeatherDeterminateType, + submitMoreCastForecastRecords +} from 'api/moreCast2API' import { getColumnGroupingModel, ColumnVis, DataGridColumns } from 'features/moreCast2/components/DataGridColumns' import ForecastDataGrid from 'features/moreCast2/components/ForecastDataGrid' import ForecastSummaryDataGrid from 'features/moreCast2/components/ForecastSummaryDataGrid' @@ -390,8 +396,8 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp // occurs on a cell in a weather model field/column and row where a forecast is being created (ie. the // row has no actual value for the weather parameter of interest) const handleCellDoubleClick = (params: GridCellParams) => { - const headerName = params.colDef.headerName as ModelType - if (!headerName || headerName === ModelChoice.ACTUAL || headerName === ModelChoice.FORECAST) { + const headerName = params.colDef.headerName as WeatherDeterminateType + if (!headerName || headerName === WeatherDeterminate.ACTUAL || headerName === WeatherDeterminate.FORECAST) { // A forecast or actual column was clicked, or there is no value for headerName, nothing to do return } diff --git a/web/src/features/moreCast2/components/colDefBuilder.test.tsx b/web/src/features/moreCast2/components/colDefBuilder.test.tsx index 192cd1c2b..ad4cd53a3 100644 --- a/web/src/features/moreCast2/components/colDefBuilder.test.tsx +++ b/web/src/features/moreCast2/components/colDefBuilder.test.tsx @@ -39,7 +39,7 @@ describe('ColDefBuilder', () => { }) ) }) - it('should generate all col defs correcty', () => { + it('should generate all col defs correctly', () => { const colDefs = colDefBuilder.generateColDefs() const expected = [ @@ -62,7 +62,7 @@ describe('ColDefBuilder', () => { headerName: determinate, sortable: false, type: 'number', - width: DEFAULT_COLUMN_WIDTH + width: determinate === WeatherDeterminate.ACTUAL ? DEFAULT_FORECAST_COLUMN_WIDTH : DEFAULT_COLUMN_WIDTH }) ) ) @@ -146,14 +146,34 @@ describe('ColDefBuilder', () => { ) expect( forecastColDef.renderCell({ row: { testField: { choice: ModelChoice.GDPS, value: 1 } }, formattedValue: 1 }) - ).toEqual() + ).toEqual( + + ) expect( forecastColDef.renderCell({ - row: { testField: { choice: ModelChoice.GDPS, value: 1 }, testActual: 2 }, + row: { testField: { choice: ModelChoice.GDPS, value: 1 } }, formattedValue: 1 }) - ).toEqual() + ).toEqual( + + ) expect(forecastColDef.valueFormatter({ value: 1.11 })).toEqual('1.1') expect( forecastColDef.valueGetter({ @@ -185,7 +205,6 @@ describe('ColDefBuilder', () => { it('should delegate to GridComponentRenderer', () => { expect(colDefBuilder.valueFormatterWith({ value: 1.11 }, 1)).toEqual('1.1') - expect(colDefBuilder.valueGetterWith({ value: 1.11 }, 1)).toEqual('1.1') expect( colDefBuilder.valueGetter( { @@ -193,7 +212,8 @@ describe('ColDefBuilder', () => { value: { choice: ModelChoice.GDPS, value: 1.11 } }, 'testField', - 1 + 1, + 'testHeader' ) ).toEqual('1.1') expect( diff --git a/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx b/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx index 1a100d9b9..b8f51b44d 100644 --- a/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx +++ b/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx @@ -2,7 +2,13 @@ import { GridColumnHeaderParams, GridValueSetterParams } from '@mui/x-data-grid' import { GridStateColDef } from '@mui/x-data-grid/internals' import { render } from '@testing-library/react' import { ModelChoice } from 'api/moreCast2API' -import { GridComponentRenderer } from 'features/moreCast2/components/GridComponentRenderer' +import { GC_HEADER } from 'features/moreCast2/components/ColumnDefBuilder' +import { + GridComponentRenderer, + NOT_AVAILABLE, + NOT_REPORTING +} from 'features/moreCast2/components/GridComponentRenderer' +import { DateTime } from 'luxon' describe('GridComponentRenderer', () => { const gridComponentRenderer = new GridComponentRenderer() @@ -27,6 +33,85 @@ describe('GridComponentRenderer', () => { expect(headerButton).toHaveTextContent('Test ID') }) + it('should render an empty cell (no N/A) if the cell is enabled and can have a forecast entered', () => { + const field = 'tempForecast' + const fieldActual = 'tempActual' + const row = { [field]: NaN, [fieldActual]: NaN, forDate: DateTime.now().plus({ days: 2 }) } + const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Forecast') + const { getByRole } = render( + gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + ) + ) + const renderedCell = getByRole('textbox') + expect(renderedCell).toBeInTheDocument() + expect(renderedCell).toHaveValue('') + expect(renderedCell).toBeEnabled() + }) + + it('should render N/A and be disabled if the cell is from a previous date', () => { + const field = 'tempForecast' + const row = { [field]: NaN, forDate: DateTime.now().minus({ days: 2 }) } + const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Forecast') + const { getByRole } = render( + gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + ) + ) + const renderedCell = getByRole('textbox') + expect(renderedCell).toBeInTheDocument() + expect(renderedCell).toHaveValue(NOT_AVAILABLE) + expect(renderedCell).toBeDisabled() + }) + + it('should render N/A and be disabled if the row has an actual', () => { + const field = 'tempForecast' + const fieldActual = 'tempActual' + const row = { [field]: NaN, [fieldActual]: 2, forDate: DateTime.now() } + const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Forecast') + const { getByRole } = render( + gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + ) + ) + const renderedCell = getByRole('textbox') + expect(renderedCell).toBeInTheDocument() + expect(renderedCell).toHaveValue(NOT_AVAILABLE) + expect(renderedCell).toBeDisabled() + }) + + it('should render N/R and be disabled if the cell is from a previous date and has no actual in the actual column', () => { + const field = 'tempForecast' + const fieldActual = 'tempActual' + const row = { [field]: NaN, [fieldActual]: NaN, forDate: DateTime.now().minus({ days: 2 }) } + const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Actual') + const { getByRole } = render( + gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + ) + ) + const renderedCell = getByRole('textbox') + expect(renderedCell).toBeInTheDocument() + expect(renderedCell).toHaveValue(NOT_REPORTING) + expect(renderedCell).toBeDisabled() + }) + it('should render the cell with the formatted value', () => { const { getByRole } = render(gridComponentRenderer.renderCellWith({ formattedValue: 1 })) @@ -47,6 +132,17 @@ describe('GridComponentRenderer', () => { expect(renderedCell).not.toBeDisabled() }) + it('should render any cell as disabled if any other cell has an actual', () => { + const field = 'grassCuringForecast' + const actual = 'tempActual' + const { getByRole } = render( + gridComponentRenderer.renderForecastCellWith({ row: { [field]: 10, [actual]: 15 } }, field) + ) + const renderedCell = getByRole('textbox') + expect(renderedCell).toBeInTheDocument() + expect(renderedCell).toBeDisabled() + }) + it('should render the forecast cell as uneditable with an actual', () => { const field = 'tempForecast' const actualField = `tempActual` @@ -82,18 +178,8 @@ describe('GridComponentRenderer', () => { }) it('should format the row correctly without a value', () => { - const formattedItemValue = gridComponentRenderer.predictionItemValueFormatter({ value: NaN }, 1) - expect(formattedItemValue).toEqual('N/A') - }) - - it('should return an existent cell value correctly', () => { - const cellValue = gridComponentRenderer.cellValueGetter({ value: 1.11 }, 1) - expect(cellValue).toEqual('1.1') - }) - - it('should return an non-existent cell value correctly', () => { - const cellValue = gridComponentRenderer.cellValueGetter({ value: NaN }, 1) - expect(cellValue).toEqual('NaN') + const formattedItemValue = gridComponentRenderer.predictionItemValueFormatter({ value: NOT_REPORTING }, 1) + expect(formattedItemValue).toEqual(NOT_REPORTING) }) it('should return an existent prediction item value correctly', () => { @@ -103,7 +189,8 @@ describe('GridComponentRenderer', () => { value: { choice: ModelChoice.GDPS, value: 1.11 } }, 1, - 'testField' + 'testField', + 'testHeader' ) expect(itemValue).toEqual('1.1') }) @@ -113,18 +200,19 @@ describe('GridComponentRenderer', () => { expect(actualField).toEqual('testActual') }) - it('should return an actual over a prediction if it exists', () => { + it('should return an actual over a prediction if it exists for grass curing', () => { const itemValue = gridComponentRenderer.valueGetter( { row: { - testForecast: { choice: ModelChoice.GDPS, value: 1.11 }, - testActual: 2.22 + grassCuringForecast: { choice: ModelChoice.GDPS, value: 10.0 }, + grassCuringActual: 20.0 }, - value: { choice: ModelChoice.GDPS, value: 1.11 } + value: { choice: ModelChoice.NULL, value: 10.0 } }, 1, - 'testForecast' + 'grassCuringForecast', + GC_HEADER ) - expect(itemValue).toEqual('2.2') + expect(itemValue).toEqual('20.0') }) }) diff --git a/web/src/features/moreCast2/util.ts b/web/src/features/moreCast2/util.ts index be153b732..1613e68ee 100644 --- a/web/src/features/moreCast2/util.ts +++ b/web/src/features/moreCast2/util.ts @@ -176,3 +176,14 @@ export const fillStationGrassCuringForward = (editedRow: MoreCast2Row, allRows: } return allRows } + +/** + * Checks if a datetime object is before the start of today + * @param datetime + * @returns boolean + */ +export const isPreviousToToday = (datetime: DateTime): boolean => { + const today = DateTime.local().startOf('day') + + return datetime < today +}