From 3d2d147e2fb640a770a968a2cea9701dc1d3d859 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 31 Mar 2026 12:02:31 -0600 Subject: [PATCH] feat(chemistry): add Major and Minor Chemistry components with summaries and integration into WellShow --- src/components/WellShow/MajorChemistry.tsx | 166 +++++++++++++++ src/components/WellShow/MinorChemistry.tsx | 190 ++++++++++++++++++ src/components/WellShow/index.ts | 2 + src/pages/ocotillo/thing/well-show.tsx | 144 +++++++++++++ .../components/WellMajorChemistry.test.tsx | 74 +++++++ .../components/WellMinorChemistry.test.tsx | 73 +++++++ src/test/pages/well-show.test.tsx | 102 ++++++++++ 7 files changed, 751 insertions(+) create mode 100644 src/components/WellShow/MajorChemistry.tsx create mode 100644 src/components/WellShow/MinorChemistry.tsx create mode 100644 src/test/components/WellMajorChemistry.test.tsx create mode 100644 src/test/components/WellMinorChemistry.test.tsx diff --git a/src/components/WellShow/MajorChemistry.tsx b/src/components/WellShow/MajorChemistry.tsx new file mode 100644 index 00000000..cc749720 --- /dev/null +++ b/src/components/WellShow/MajorChemistry.tsx @@ -0,0 +1,166 @@ +import { Box, Paper, Stack, TextField, Typography } from '@mui/material' +import { ScienceOutlined } from '@mui/icons-material' +import { formatAppDate } from '@/utils/Date' + +export type MajorChemistryFeature = { + id?: string | number + properties?: Record +} + +export type MajorChemistrySummary = { + chemistryDate: string + tds: string + calcium: string + magnesium: string + sodium: string + potassium: string + chloride: string + sulfate: string + bicarbonate: string + carbonate: string +} + +const getString = (value: unknown): string | null => { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +const getComparableName = (value: unknown): string | null => + getString(value)?.toLowerCase() ?? null + +const formatValueWithUnit = ( + properties: Record, + field: string +): string => { + const value = properties[field] + const unit = getString(properties[`${field}_units`]) + + const hasNumber = typeof value === 'number' && Number.isFinite(value) + const hasString = typeof value === 'string' && value.trim().length > 0 + + if (!hasNumber && !hasString) return 'N/A' + + const displayValue = hasNumber ? String(value) : String(value).trim() + return unit ? `${displayValue} ${unit}` : displayValue +} + +export const matchesMajorChemistryFeatureToWell = ({ + feature, + wellName, +}: { + feature: MajorChemistryFeature + wellName?: string | null +}): boolean => { + const properties = feature.properties ?? {} + const comparableWellName = getComparableName(wellName) + const comparableName = getComparableName(properties.name) + + if (!comparableWellName) return false + + return comparableName === comparableWellName +} + +export const normalizeMajorChemistrySummary = ({ + feature, +}: { + feature?: MajorChemistryFeature | null +}): MajorChemistrySummary | null => { + const properties = feature?.properties + if (!properties) return null + + const chemistryDateRaw = + getString(properties.latest_chemistry_date) ?? + getString(properties.sample_date) + + return { + chemistryDate: chemistryDateRaw + ? formatAppDate(chemistryDateRaw) || chemistryDateRaw + : 'N/A', + tds: formatValueWithUnit(properties, 'tds'), + calcium: formatValueWithUnit(properties, 'calcium'), + magnesium: formatValueWithUnit(properties, 'magnesium'), + sodium: formatValueWithUnit(properties, 'sodium'), + potassium: formatValueWithUnit(properties, 'potassium'), + chloride: formatValueWithUnit(properties, 'chloride'), + sulfate: formatValueWithUnit(properties, 'sulfate'), + bicarbonate: formatValueWithUnit(properties, 'bicarbonate'), + carbonate: formatValueWithUnit(properties, 'carbonate'), + } +} + +const SummaryField = ({ label, value }: { label: string; value: string }) => ( + +) + +export const MajorChemistryAccordion = ({ + summary, + isLoading, +}: { + summary?: MajorChemistrySummary | null + isLoading: boolean +}) => { + return ( + + + + + Major Chemistry + + + + {!summary && !isLoading ? ( + + No major chemistry summary found. + + ) : ( + + + + + + + + + + + + + )} + + + ) +} diff --git a/src/components/WellShow/MinorChemistry.tsx b/src/components/WellShow/MinorChemistry.tsx new file mode 100644 index 00000000..4b0e52b7 --- /dev/null +++ b/src/components/WellShow/MinorChemistry.tsx @@ -0,0 +1,190 @@ +import { Box, Paper, Stack, TextField, Typography } from '@mui/material' +import { ScienceOutlined } from '@mui/icons-material' +import { formatAppDate } from '@/utils/Date' + +export type MinorChemistryFeature = { + id?: string | number + properties?: Record +} + +export type MinorChemistrySummary = { + chemistryDate: string + h2r: string + o18r: string + c13r: string + c14: string + c14Years: string + fluoride: string + bromide: string + arsenic: string + uranium: string + iron: string + manganese: string + barium: string + nitrate: string + nitrateAsN: string +} + +const getString = (value: unknown): string | null => { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +const getComparableName = (value: unknown): string | null => + getString(value)?.toLowerCase() ?? null + +const formatValueWithUnit = ( + properties: Record, + field: string +): string => { + const value = properties[field] + const unit = getString(properties[`${field}_units`]) + + const hasNumber = typeof value === 'number' && Number.isFinite(value) + const hasString = typeof value === 'string' && value.trim().length > 0 + + if (!hasNumber && !hasString) return 'N/A' + + const displayValue = hasNumber ? String(value) : String(value).trim() + return unit ? `${displayValue} ${unit}` : displayValue +} + +export const matchesMinorChemistryFeatureToWell = ({ + feature, + wellName, +}: { + feature: MinorChemistryFeature + wellName?: string | null +}): boolean => { + const properties = feature.properties ?? {} + const comparableWellName = getComparableName(wellName) + const comparableName = getComparableName(properties.name) + + if (!comparableWellName) return false + + return comparableName === comparableWellName +} + +export const normalizeMinorChemistrySummary = ({ + feature, +}: { + feature?: MinorChemistryFeature | null +}): MinorChemistrySummary | null => { + const properties = feature?.properties + if (!properties) return null + + const chemistryDateRaw = + getString(properties.latest_chemistry_date) ?? + getString(properties.sample_date) + + return { + chemistryDate: chemistryDateRaw + ? formatAppDate(chemistryDateRaw) || chemistryDateRaw + : 'N/A', + h2r: formatValueWithUnit(properties, 'h2r'), + o18r: formatValueWithUnit(properties, 'o18r'), + c13r: formatValueWithUnit(properties, 'c13r'), + c14: formatValueWithUnit(properties, 'c14'), + c14Years: formatValueWithUnit(properties, 'c14_years'), + fluoride: formatValueWithUnit(properties, 'fluoride'), + bromide: formatValueWithUnit(properties, 'bromide'), + arsenic: formatValueWithUnit(properties, 'arsenic'), + uranium: formatValueWithUnit(properties, 'uranium'), + iron: formatValueWithUnit(properties, 'iron'), + manganese: formatValueWithUnit(properties, 'manganese'), + barium: formatValueWithUnit(properties, 'barium'), + nitrate: formatValueWithUnit(properties, 'nitrate'), + nitrateAsN: formatValueWithUnit(properties, 'nitrate_as_n'), + } +} + +const SummaryField = ({ label, value }: { label: string; value: string }) => ( + +) + +export const MinorChemistryAccordion = ({ + summary, + isLoading, +}: { + summary?: MinorChemistrySummary | null + isLoading: boolean +}) => { + return ( + + + + + Minor Chemistry + + + + {!summary && !isLoading ? ( + + No minor chemistry summary found. + + ) : ( + + + + + + + + + + + + + + + + + + )} + + + ) +} diff --git a/src/components/WellShow/index.ts b/src/components/WellShow/index.ts index cb4005c1..d25e8f0d 100644 --- a/src/components/WellShow/index.ts +++ b/src/components/WellShow/index.ts @@ -7,6 +7,8 @@ export * from './AlternateIds' export * from './Contacts' export * from './Equipment' export * from './FieldEventHistory' +export * from './MajorChemistry' +export * from './MinorChemistry' export * from './Notes' export * from './WellScreens' export * from './WellShowTitle' diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 64ba8579..9215cde8 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { Show, useDataGrid } from '@refinedev/mui' import { AppBreadcrumb } from '@/components/AppBreadcrumb' import { TransducerObservationWithBlockResponse } from '@/generated/types.gen' +import { OgcCollectionRecord, resolveCollection } from '@/utils/ogcLayerUtils' import { IAsset, IContact, @@ -36,10 +37,32 @@ import { GeologyInformationAccordion, WellPhysicalPropertiesAccordion, FieldEventHistoryAccordion, + MajorChemistryAccordion, + normalizeMajorChemistrySummary, + MinorChemistryAccordion, + normalizeMinorChemistrySummary, WellPDFDownloadButton, WellShowTitle, OwnerPermissionsCard, } from '@/components' +import type { MajorChemistryFeature } from '@/components/WellShow/MajorChemistry' +import type { MinorChemistryFeature } from '@/components/WellShow/MinorChemistry' + +const MAJOR_CHEMISTRY_COLLECTION_CANDIDATES = [ + 'Major Chemistry (Water Wells)', + 'major_chemistry_results', + 'major_chemistry_wells', + 'major_chemistry', +] + +const MINOR_CHEMISTRY_COLLECTION_CANDIDATES = [ + 'Minor Chemistry (Water Wells)', + 'minor_chemistry_wells', + 'minor_chemistry_results', + 'minor_chemistry', +] + +const MAJOR_CHEMISTRY_PAGE_SIZE = 50 export const WellShow = () => { const dataProvider = useDataProvider() @@ -47,6 +70,10 @@ export const WellShow = () => { () => dataProvider('ocotillo'), [dataProvider] ) + const ogcapiDataProvider = useMemo( + () => dataProvider('ogcapi'), + [dataProvider] + ) const { id } = useResourceParams() const detailsQuery = useQuery({ @@ -74,6 +101,7 @@ export const WellShow = () => { }, }) const well = detailsQuery.data?.well + const wellName = well?.name ?? null const observations = detailsQuery.data?.recent_groundwater_level_observations ?? [] const assets = assetResult?.data ?? [] @@ -173,6 +201,114 @@ export const WellShow = () => { const manualHydrographRows = hydrographQuery.data?.manualRows ?? [] const transducerHydrographRows = hydrographQuery.data?.transducerRows ?? [] + const majorChemistryQuery = useQuery({ + queryKey: ['well-major-chemistry', id, wellName], + enabled: Boolean(id && wellName), + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + queryFn: async () => { + const collectionsResult = await ogcapiDataProvider.getList({ + resource: 'ogcapi', + pagination: { currentPage: 1, pageSize: 200 }, + }) + + const resolvedCollection = resolveCollection( + (collectionsResult?.data ?? []) as OgcCollectionRecord[], + MAJOR_CHEMISTRY_COLLECTION_CANDIDATES + ) + + if (!resolvedCollection.exists || !resolvedCollection.id) { + return null + } + + const collection = resolvedCollection.id + + const getFeatures = async (params: Record) => { + const page = await ogcapiDataProvider.getList({ + resource: 'ogcapi', + pagination: { + currentPage: 1, + pageSize: MAJOR_CHEMISTRY_PAGE_SIZE, + }, + meta: { + requestConfig: { + params: { + collection, + f: 'json', + ...params, + }, + }, + }, + }) + + return (page?.data ?? []) as MajorChemistryFeature[] + } + + const serverSideNameRows = await getFeatures({ name: wellName as string }) + if (serverSideNameRows.length > 0) return serverSideNameRows[0] + + return null + }, + }) + + const majorChemistrySummary = useMemo( + () => + normalizeMajorChemistrySummary({ + feature: (majorChemistryQuery.data as MajorChemistryFeature | null) ?? null, + }), + [majorChemistryQuery.data] + ) + + const minorChemistryQuery = useQuery({ + queryKey: ['well-minor-chemistry', id, wellName], + enabled: Boolean(id && wellName), + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + queryFn: async () => { + const collectionsResult = await ogcapiDataProvider.getList({ + resource: 'ogcapi', + pagination: { currentPage: 1, pageSize: 200 }, + }) + + const resolvedCollection = resolveCollection( + (collectionsResult?.data ?? []) as OgcCollectionRecord[], + MINOR_CHEMISTRY_COLLECTION_CANDIDATES + ) + + if (!resolvedCollection.exists || !resolvedCollection.id) { + return null + } + + const page = await ogcapiDataProvider.getList({ + resource: 'ogcapi', + pagination: { + currentPage: 1, + pageSize: MAJOR_CHEMISTRY_PAGE_SIZE, + }, + meta: { + requestConfig: { + params: { + collection: resolvedCollection.id, + f: 'json', + name: wellName as string, + }, + }, + }, + }) + + const rows = (page?.data ?? []) as MinorChemistryFeature[] + return rows.length > 0 ? rows[0] : null + }, + }) + + const minorChemistrySummary = useMemo( + () => + normalizeMinorChemistrySummary({ + feature: (minorChemistryQuery.data as MinorChemistryFeature | null) ?? null, + }), + [minorChemistryQuery.data] + ) + const hydrographDatasource = useMemo(() => { const manualSource = manualHydrographRows.length > 0 @@ -310,6 +446,14 @@ export const WellShow = () => { + + diff --git a/src/test/components/WellMajorChemistry.test.tsx b/src/test/components/WellMajorChemistry.test.tsx new file mode 100644 index 00000000..2099427d --- /dev/null +++ b/src/test/components/WellMajorChemistry.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { + MajorChemistryAccordion, + matchesMajorChemistryFeatureToWell, + normalizeMajorChemistrySummary, +} from '@/components/WellShow/MajorChemistry' + +describe('major chemistry helpers', () => { + it('matches the flattened name field and does not rely on thing_name or nested thing.name', () => { + expect( + matchesMajorChemistryFeatureToWell({ + feature: { + properties: { + name: 'Well A', + }, + }, + wellName: 'Well A', + }) + ).toBe(true) + + expect( + matchesMajorChemistryFeatureToWell({ + feature: { + properties: { + thing_name: 'Well A', + }, + }, + wellName: 'Well A', + }) + ).toBe(false) + + expect( + matchesMajorChemistryFeatureToWell({ + feature: { + properties: { + thing: { name: 'Well A' }, + }, + }, + wellName: 'Well A', + }) + ).toBe(false) + }) + + it('normalizes a single feature summary and renders missing analytes as N/A', () => { + const summary = normalizeMajorChemistrySummary({ + feature: { + id: 'latest', + properties: { + name: 'Well A', + latest_chemistry_date: '2025-02-15T00:00:00Z', + tds: 250, + tds_units: 'mg/L', + calcium: 10, + calcium_units: 'mg/L', + }, + }, + }) + + expect(summary?.tds).toBe('250 mg/L') + expect(summary?.calcium).toBe('10 mg/L') + expect(summary?.magnesium).toBe('N/A') + }) +}) + +describe('MajorChemistryAccordion', () => { + it('shows the empty state when no summary exists', () => { + render() + + expect(screen.getByText('No major chemistry summary found.')).toBeTruthy() + }) +}) diff --git a/src/test/components/WellMinorChemistry.test.tsx b/src/test/components/WellMinorChemistry.test.tsx new file mode 100644 index 00000000..20811b1a --- /dev/null +++ b/src/test/components/WellMinorChemistry.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { + MinorChemistryAccordion, + matchesMinorChemistryFeatureToWell, + normalizeMinorChemistrySummary, +} from '@/components/WellShow/MinorChemistry' + +describe('minor chemistry helpers', () => { + it('matches the flattened name field and does not rely on nested thing.name', () => { + expect( + matchesMinorChemistryFeatureToWell({ + feature: { + properties: { + name: 'Well A', + }, + }, + wellName: 'Well A', + }) + ).toBe(true) + + expect( + matchesMinorChemistryFeatureToWell({ + feature: { + properties: { + thing: { name: 'Well A' }, + }, + }, + wellName: 'Well A', + }) + ).toBe(false) + }) + + it('normalizes a single feature summary and renders missing analytes as N/A', () => { + const summary = normalizeMinorChemistrySummary({ + feature: { + id: 'latest', + properties: { + name: 'Well A', + latest_chemistry_date: '2025-02-15T00:00:00Z', + h2r: -90.4, + c14: 73, + c14_units: 'pmc', + o18r: -12.7, + fluoride: 0.8, + fluoride_units: 'mg/L', + arsenic: 0.004, + arsenic_units: 'mg/L', + nitrate_as_n: 1.2, + nitrate_as_n_units: 'mg/L', + }, + }, + }) + + expect(summary?.h2r).toBe('-90.4') + expect(summary?.c14).toBe('73 pmc') + expect(summary?.o18r).toBe('-12.7') + expect(summary?.fluoride).toBe('0.8 mg/L') + expect(summary?.arsenic).toBe('0.004 mg/L') + expect(summary?.nitrateAsN).toBe('1.2 mg/L') + expect(summary?.bromide).toBe('N/A') + }) +}) + +describe('MinorChemistryAccordion', () => { + it('shows the empty state when no summary exists', () => { + render() + + expect(screen.getByText('No minor chemistry summary found.')).toBeTruthy() + }) +}) diff --git a/src/test/pages/well-show.test.tsx b/src/test/pages/well-show.test.tsx index d5c03689..aa3f875b 100644 --- a/src/test/pages/well-show.test.tsx +++ b/src/test/pages/well-show.test.tsx @@ -60,6 +60,10 @@ vi.mock('@/components', () => { GeologyInformationAccordion: () => , WellPhysicalPropertiesAccordion: () => , FieldEventHistoryAccordion: () => , + MajorChemistryAccordion: () => , + normalizeMajorChemistrySummary: () => null, + MinorChemistryAccordion: () => , + normalizeMinorChemistrySummary: () => null, WellPDFDownloadButton: () => , WellShowTitle: () => , OwnerPermissionsCard: () => , @@ -104,6 +108,20 @@ describe('WellShow data loading', () => { } } + if (args?.queryKey?.[0] === 'well-major-chemistry') { + return { + data: null, + isLoading: false, + } + } + + if (args?.queryKey?.[0] === 'well-minor-chemistry') { + return { + data: [], + isLoading: false, + } + } + return { data: { manualRows: [], transducerRows: [] }, isLoading: false, @@ -140,6 +158,26 @@ describe('WellShow data loading', () => { enabled: true, }) ) + + expect(mockedUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['well-major-chemistry', '42', 'Test Well'], + enabled: true, + }) + ) + + expect(mockedUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['well-minor-chemistry', '42', 'Test Well'], + enabled: true, + }) + ) + + expect(document.body.textContent).toContain('major-chemistry') + expect(document.body.textContent).toContain('minor-chemistry') + expect(document.body.textContent?.indexOf('major-chemistry')).toBeLessThan( + document.body.textContent?.indexOf('minor-chemistry') ?? Infinity + ) }) it('keeps well-scoped queries disabled until an id exists', () => { @@ -167,5 +205,69 @@ describe('WellShow data loading', () => { enabled: false, }) ) + + expect(mockedUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['well-major-chemistry', undefined, 'Test Well'], + enabled: false, + }) + ) + + expect(mockedUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['well-minor-chemistry', undefined, 'Test Well'], + enabled: false, + }) + ) + }) + + it('keeps the major chemistry query disabled until the well name exists', () => { + mockedUseQuery.mockImplementation((args: any) => { + if (args?.queryKey?.[0] === 'well-details') { + return { + data: { + well: { id: 42, name: undefined }, + contacts: [], + sensors: [], + deployments: [], + well_screens: [], + recent_groundwater_level_observations: [], + latest_field_event_sample: null, + }, + isLoading: false, + } + } + + if ( + args?.queryKey?.[0] === 'well-major-chemistry' || + args?.queryKey?.[0] === 'well-minor-chemistry' + ) { + return { + data: [], + isLoading: false, + } + } + + return { + data: { manualRows: [], transducerRows: [] }, + isLoading: false, + } + }) + + render() + + expect(mockedUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['well-major-chemistry', '42', null], + enabled: false, + }) + ) + + expect(mockedUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['well-minor-chemistry', '42', null], + enabled: false, + }) + ) }) })