diff --git a/web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx b/web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx index e97323dd8..e168c1d50 100644 --- a/web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx +++ b/web/src/features/sfmsInsights/pages/SFMSInsightsPage.test.tsx @@ -95,6 +95,10 @@ vi.mock('@/features/sfmsInsights/components/RasterTypeDropdown', () => ({ ) })) +// Create spies for local storage +const setItemSpy = vi.spyOn(Storage.prototype, 'setItem') +const getItemSpy = vi.spyOn(Storage.prototype, 'getItem') + describe('SFMSInsightsPage', () => { const dateTimeNow = DateTime.fromISO('2025-11-02') const dateTimeNowPlusTen = dateTimeNow.plus({ days: 10 }) @@ -146,7 +150,8 @@ describe('SFMSInsightsPage', () => { } } - const renderWithStore = (sfmsBounds?: any) => { + const renderWithStore = (hideModal: string, sfmsBounds?: any) => { + getItemSpy.mockReturnValue(hideModal) const store = createTestStore({ authentication: defaultAuthentication, runDates: { @@ -196,10 +201,13 @@ describe('SFMSInsightsPage', () => { } } }) + localStorage.clear() + getItemSpy.mockClear() + setItemSpy.mockClear() }) it('should load rasterDate from SFMS bounds in store', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() // Verify that the rasterDate was set from the sfmsBounds in the store @@ -209,7 +217,7 @@ describe('SFMSInsightsPage', () => { }) it('should set date picker max date based on SFMS bounds', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() // The date picker should be rendered with max date from SFMS bounds (2025-11-02) @@ -218,21 +226,21 @@ describe('SFMSInsightsPage', () => { }) it('should render the snow checkbox', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const checkbox = screen.getByRole('checkbox', { name: /show latest snow/i }) expect(checkbox).toBeInTheDocument() }) it('should have the snow checkbox checked by default', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const checkbox = screen.getByRole('checkbox', { name: /show latest snow/i }) as HTMLInputElement expect(checkbox.checked).toBe(true) }) it('should toggle snow checkbox when clicked', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const checkbox = screen.getByRole('checkbox', { name: /show latest snow/i }) as HTMLInputElement @@ -246,7 +254,7 @@ describe('SFMSInsightsPage', () => { }) it('should pass showSnow prop to SFMSMap when checkbox is checked', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const map = screen.getByTestId('sfms-map') @@ -254,7 +262,7 @@ describe('SFMSInsightsPage', () => { }) it('should pass showSnow=false to SFMSMap when checkbox is unchecked', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const checkbox = screen.getByRole('checkbox', { name: /show latest snow/i }) @@ -267,7 +275,7 @@ describe('SFMSInsightsPage', () => { }) it('should render raster type dropdown next to snow checkbox', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const rasterDropdown = screen.getByTestId('raster-type-dropdown') @@ -278,14 +286,14 @@ describe('SFMSInsightsPage', () => { }) it('should fetch snow data on mount with initial rasterDate', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() expect(getMostRecentProcessedSnowByDate).toHaveBeenCalledWith(DateTime.fromISO('2025-11-02')) }) it('should pass fetched snow date to SFMSMap', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const map = screen.getByTestId('sfms-map') @@ -294,7 +302,7 @@ describe('SFMSInsightsPage', () => { }) it('should display snow date in checkbox label when available', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const checkbox = screen.getByRole('checkbox', { name: /show latest snow: nov 2, 2025/i }) @@ -304,7 +312,7 @@ describe('SFMSInsightsPage', () => { it('should display "Show Latest Snow" without date when no snow data available', async () => { ;(getMostRecentProcessedSnowByDate as Mock).mockResolvedValue(null) - renderWithStore() + renderWithStore('true') await waitForPageLoad() const checkbox = screen.getByRole('checkbox', { name: 'Show Latest Snow' }) @@ -324,7 +332,7 @@ describe('SFMSInsightsPage', () => { snowSource: 'viirs' }) - renderWithStore() + renderWithStore('true') await waitForPageLoad() // Wait for initial fetch @@ -349,7 +357,7 @@ describe('SFMSInsightsPage', () => { }) it('should set maxDate from latestSFMSBounds.maximum', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const maxDate = screen.getByTestId('historical-max-date') @@ -357,7 +365,7 @@ describe('SFMSInsightsPage', () => { }) it('should set minDate from earliestSFMSBounds.minimum', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const minDate = screen.getByTestId('historical-min-date') @@ -365,7 +373,7 @@ describe('SFMSInsightsPage', () => { }) it('should pass both min and max dates to ASADatePicker', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const minDate = screen.getByTestId('current-year-min-date') @@ -376,7 +384,7 @@ describe('SFMSInsightsPage', () => { }) it('should update min bounds when latestBounds changes', async () => { - renderWithStore({ + renderWithStore('true', { '2025': { forecast: { minimum: '2025-05-01', @@ -394,7 +402,7 @@ describe('SFMSInsightsPage', () => { // Mock getSFMSBounds to return null ;(getSFMSBounds as Mock).mockResolvedValueOnce({ sfms_bounds: null }) - renderWithStore(null) + renderWithStore('true', null) // Wait for fetch to complete await waitFor(() => { @@ -413,7 +421,7 @@ describe('SFMSInsightsPage', () => { }) it('should set rasterDate to today when latestBounds.maximum is empty', async () => { - renderWithStore({ + renderWithStore('true', { '2025': { forecast: { minimum: '2025-05-01', @@ -435,7 +443,7 @@ describe('SFMSInsightsPage', () => { }) it('should not set minDate when earliestBounds.minimum is empty', async () => { - renderWithStore({ + renderWithStore('true', { '2025': { forecast: { minimum: '', @@ -452,7 +460,7 @@ describe('SFMSInsightsPage', () => { }) it('should set rasterDate to today when all years have empty maximum', async () => { - renderWithStore({ + renderWithStore('true', { '2024': { forecast: { minimum: '2024-01-01', @@ -480,17 +488,134 @@ describe('SFMSInsightsPage', () => { }) it('should disable raster dropdown options when no SFMS bounds data available', async () => { - renderWithStore(null) + renderWithStore('true', null) const dropdown = screen.getByTestId('raster-type-dropdown') expect(dropdown).toHaveAttribute('data-raster-data-available', 'false') }) it('should enable raster dropdown options when SFMS bounds data available', async () => { - renderWithStore() + renderWithStore('true') await waitForPageLoad() const dropdown = screen.getByTestId('raster-type-dropdown') expect(dropdown).toHaveAttribute('data-raster-data-available', 'true') }) + + it('should display warning tooltip when rasterDate greater than March 8, 2026 and snowDate is March 8, 2026.', async () => { + ;(getDateTimeNowPST as Mock).mockReturnValue(DateTime.fromISO('2026-03-10')) + ;(getMostRecentProcessedSnowByDate as Mock).mockResolvedValue({ + forDate: DateTime.fromISO('2026-03-08'), + processedDate: DateTime.fromISO('2025-03-09'), + snowSource: 'viirs' + }) + + renderWithStore('true') + await waitForPageLoad() + + const warningIcon = screen.queryByTestId('WarningAmberIcon') + expect(warningIcon).toBeInTheDocument() + }) + + it('should not display warning tooltip when rasterDate equal to March 8, 2026 and snowDate is March 8, 2026.', async () => { + ;(getDateTimeNowPST as Mock).mockReturnValue(DateTime.fromISO('2026-03-08')) + ;(getMostRecentProcessedSnowByDate as Mock).mockResolvedValue({ + forDate: DateTime.fromISO('2026-03-08'), + processedDate: DateTime.fromISO('2025-03-09'), + snowSource: 'viirs' + }) + + renderWithStore('true') + await waitForPageLoad() + + const warningIcon = screen.queryByTestId('WarningAmberIcon') + expect(warningIcon).toBe(null) + }) + + it('should not display warning tooltip when rasterDate greater than March 8, 2026 and snowDate greater than March 8, 2026.', async () => { + ;(getDateTimeNowPST as Mock).mockReturnValue(DateTime.fromISO('2026-03-09')) + ;(getMostRecentProcessedSnowByDate as Mock).mockResolvedValue({ + forDate: DateTime.fromISO('2026-03-10'), + processedDate: DateTime.fromISO('2025-03-09'), + snowSource: 'viirs' + }) + + renderWithStore('true') + await waitForPageLoad() + + const warningIcon = screen.queryByTestId('WarningAmberIcon') + expect(warningIcon).toBe(null) + }) + + it('should not display warning tooltip when rasterDate less than March 8, 2026 and snowDate greater than March 8, 2026.', async () => { + ;(getDateTimeNowPST as Mock).mockReturnValue(DateTime.fromISO('2026-03-07')) + ;(getMostRecentProcessedSnowByDate as Mock).mockResolvedValue({ + forDate: DateTime.fromISO('2026-03-10'), + processedDate: DateTime.fromISO('2025-03-09'), + snowSource: 'viirs' + }) + + renderWithStore('true') + await waitForPageLoad() + + const warningIcon = screen.queryByTestId('WarningAmberIcon') + expect(warningIcon).toBe(null) + }) + + it("should display modal warning when user hasn't permanently dismissed", async () => { + renderWithStore('false') + await waitForPageLoad() + const modalHeading = screen.queryByText('Snow Coverage Imagery Warning') + expect(modalHeading).toBeInTheDocument() + }) + + it('should not display modal warning if user has permanently dismissed', async () => { + renderWithStore('true') + await waitForPageLoad() + const modalHeading = screen.queryByText('Snow Coverage Imagery Warning') + expect(modalHeading).not.toBeInTheDocument() + }) + + it('modal dismiss button should dismiss modal', async () => { + renderWithStore('false') + await waitForPageLoad() + + const modalHeading = screen.queryByText('Snow Coverage Imagery Warning') + expect(modalHeading).toBeInTheDocument() + + const dismissButton = screen.getByRole('button', { name: 'Dismiss' }) + expect(dismissButton).toBeInTheDocument() + + fireEvent.click(dismissButton) + + await waitFor(() => { + const modalHeading = screen.queryByText('Snow Coverage Imagery Warning') + expect(modalHeading).not.toBeInTheDocument() + }) + }) + + it("modal don't show again toggle should call localStorage.setItem", async () => { + renderWithStore('false') + await waitForPageLoad() + + const checkbox = screen.getByRole('checkbox', { name: /don't show again/i }) + expect(checkbox).toBeInTheDocument() + expect(checkbox).not.toBeChecked() + + // Toggle checkbox on + fireEvent.click(checkbox) + + await waitFor(() => { + expect(checkbox).toBeChecked() + expect(setItemSpy).toHaveBeenCalledWith('SFMSInsightsAlwaysHideSnowMessage', 'true') + }) + + // Toggle checkbox off + fireEvent.click(checkbox) + + await waitFor(() => { + expect(checkbox).not.toBeChecked() + expect(setItemSpy).toHaveBeenCalledWith('SFMSInsightsAlwaysHideSnowMessage', 'false') + }) + }) }) diff --git a/web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx b/web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx index 14280ec5f..d57532545 100644 --- a/web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx +++ b/web/src/features/sfmsInsights/pages/SFMSInsightsPage.tsx @@ -1,21 +1,37 @@ +import { getMostRecentProcessedSnowByDate } from '@/api/snow' +import { AppDispatch } from '@/app/store' +import { theme } from '@/app/theme' import { GeneralHeader } from '@/components/GeneralHeader' +import { StyledFormControl } from '@/components/StyledFormControl' +import ASADatePicker from '@/features/fba/components/ASADatePicker' +import { fetchSFMSBounds, selectEarliestSFMSBounds, selectLatestSFMSBounds } from '@/features/fba/slices/runDatesSlice' import Footer from '@/features/landingPage/components/Footer' +import { RasterType } from '@/features/sfmsInsights/components/map/rasterConfig' import SFMSMap from '@/features/sfmsInsights/components/map/SFMSMap' -import ASADatePicker from '@/features/fba/components/ASADatePicker' import RasterTypeDropdown from '@/features/sfmsInsights/components/RasterTypeDropdown' -import { StyledFormControl } from '@/components/StyledFormControl' import { SFMS_INSIGHTS_NAME } from '@/utils/constants' -import { getMostRecentProcessedSnowByDate } from '@/api/snow' -import { fetchSFMSBounds, selectLatestSFMSBounds, selectEarliestSFMSBounds } from '@/features/fba/slices/runDatesSlice' -import { Box, Checkbox, FormControlLabel, Grid, CircularProgress } from '@mui/material' +import { getDateTimeNowPST } from '@/utils/date' +import WarningAmberIcon from '@mui/icons-material/WarningAmber' +import { + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Grid, + IconButton +} from '@mui/material' +import { isNil, isNull } from 'lodash' import { DateTime } from 'luxon' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { isNull } from 'lodash' -import { RasterType } from '@/features/sfmsInsights/components/map/rasterConfig' -import { getDateTimeNowPST } from '@/utils/date' -import { AppDispatch } from '@/app/store' +const SFMS_INSIGHTS_ALWAYS_HIDE_KEY = 'SFMSInsightsAlwaysHideSnowMessage' +const TEMP_LAST_SNOW = DateTime.fromISO('2026-03-08') export const SFMSInsightsPage = () => { const dispatch = useDispatch() const latestBounds = useSelector(selectLatestSFMSBounds) @@ -31,6 +47,20 @@ export const SFMSInsightsPage = () => { const [rasterType, setRasterType] = useState('fuel') const [showSnow, setShowSnow] = useState(true) + const [showSnowDialog, setShowSnowDialog] = useState(false) + const [alwaysHide, setAlwaysHide] = useState(() => { + const stored = localStorage.getItem(SFMS_INSIGHTS_ALWAYS_HIDE_KEY) + return stored === 'true' + }) + + const showSnowWarningIcon = useMemo(() => { + return ( + !isNil(snowDate?.ordinal) && + snowDate?.ordinal === TEMP_LAST_SNOW.ordinal && + !isNil(rasterDate?.ordinal) && + rasterDate?.ordinal > TEMP_LAST_SNOW.ordinal + ) + }, [rasterDate, snowDate]) useEffect(() => { // Only fetch SFMS bounds if we haven't fetched yet (undefined) and aren't already loading @@ -40,6 +70,13 @@ export const SFMSInsightsPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + // Display warning about stale snow data if user hasn't chosen to always hide the dialog + if (showSnow && !alwaysHide) { + setShowSnowDialog(true) + } + }, [alwaysHide, showSnow]) + useEffect(() => { if (earliestBounds?.minimum) { setMinDate(DateTime.fromISO(earliestBounds.minimum)) @@ -66,6 +103,57 @@ export const SFMSInsightsPage = () => { fetchLastProcessedSnow(rasterDate) }, [rasterDate]) + const handleAlwaysHide = () => { + localStorage.setItem(SFMS_INSIGHTS_ALWAYS_HIDE_KEY, `${!alwaysHide}`) + setAlwaysHide(!alwaysHide) + } + + const renderSnowWarningIcon = () => { + if (showSnowWarningIcon) { + return ( + setShowSnowDialog(true)} sx={{ pl: 0, pt: 0 }}> + + + ) + } + } + + const renderSnowWarningDialog = () => { + if (showSnow && showSnowDialog) { + return ( + + + + + Snow Coverage Imagery Warning + + + + Snow coverage data is unavailable from March 9, 2026 to the present. The VIIRS satellite sensor that + supplies snow coverage imagery experienced an anomaly on March 9, 2026 and updated imagery is unavailable. + + + + } + label="Don't show again" + style={{ fontSize: '0.8rem', color: theme.palette.warning.contrastText }} + /> + + + + ) + } + } + return ( @@ -114,12 +202,14 @@ export const SFMSInsightsPage = () => { control={ setShowSnow(e.target.checked)} />} label={snowDate ? `Show Latest Snow: ${snowDate.toLocaleString(DateTime.DATE_MED)}` : 'Show Latest Snow'} /> + {renderSnowWarningIcon()} + {renderSnowWarningDialog()}