diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 0083504d..c9f97de0 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -469,6 +469,7 @@ function AppSidebar() { ) } +const SANDBOX_CHEMISTRY_REPORT = '/ocotillo/chemistry-report' const SANDBOX_GEOTHERMAL_GRID = '/geothermal/wells/records-grid' const SANDBOX_GEOTHERMAL_INVENTORY = '/geothermal/wells/inventory' const SANDBOX_GEOTHERMAL_TEMP_DEPTH = '/geothermal/wells/temp-depth' @@ -476,6 +477,7 @@ const SANDBOX_GEOTHERMAL_TEMP_DEPTH = '/geothermal/wells/temp-depth' function isSandboxPath(pathname: string): boolean { return ( pathname.startsWith('/example') || + pathname.startsWith(SANDBOX_CHEMISTRY_REPORT) || pathname.startsWith(SANDBOX_GEOTHERMAL_GRID) || pathname.startsWith(SANDBOX_GEOTHERMAL_INVENTORY) || pathname.startsWith(SANDBOX_GEOTHERMAL_TEMP_DEPTH) @@ -516,6 +518,19 @@ function ExampleNavItem() { Typography + {/* Gated on the AMP.Staging group via ocotillo.chemistry-report. */} + + + + Chemistry Reports + + + { + const { open: notify } = useNotification() + const [isGenerating, setIsGenerating] = useState(false) + + const handleDownload = async () => { + if (!well) return + + try { + setIsGenerating(true) + const filename = await downloadChemistryReport({ + well, + contacts, + observations, + waterLevels, + year, + sections, + }) + + notify?.({ + message: 'Chemistry report generated', + type: 'success', + description: filename, + }) + } catch (error) { + console.error(error) + notify?.({ + message: 'Chemistry report generation failed', + type: 'error', + }) + } finally { + setIsGenerating(false) + } + } + + return ( + + ) +} diff --git a/src/components/Button/WellPDFActions.tsx b/src/components/Button/WellPDFActions.tsx index ddd61c17..83aa22a0 100644 --- a/src/components/Button/WellPDFActions.tsx +++ b/src/components/Button/WellPDFActions.tsx @@ -1,20 +1,36 @@ -import { useState } from 'react' +import { pdf } from '@react-pdf/renderer' import { BaseRecord, useGo, useNotification } from '@refinedev/core' -import { useParams } from 'react-router' import { DownloadIcon, EyeIcon } from 'lucide-react' +import { useState } from 'react' +import { useParams } from 'react-router' +import { WellPDF } from '@/components' +import { downloadChemistryReport } from '@/components/pdf/chemistry' import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' -import { WellPDF } from '@/components' -import { buildPdfFilename, SensorDeploymentRow } from '@/utils' -import { pdf } from '@react-pdf/renderer' -import { IContact, IObservation, ISample, IWell } from '@/interfaces/ocotillo' -import { IPdfOptions } from '@/interfaces' import { PDF_SINGLE_PAGE_OPTION } from '@/config' -import { useAccessCapabilities } from '@/hooks' +import { useAccessCapabilities, useWellChemistryReport } from '@/hooks' +import { IPdfOptions } from '@/interfaces' +import { IContact, IObservation, ISample, IWell } from '@/interfaces/ocotillo' +import { buildPdfFilename, SensorDeploymentRow } from '@/utils' + +/** The kinds of PDF the well details page can produce. */ +export type WellReportType = 'field-sheet' | 'chemistry-report' + +const REPORT_TYPE_LABELS: Record = { + 'field-sheet': 'Field sheet', + 'chemistry-report': 'Chemistry report', +} type WellPDFActionsButtonProps = { isPreviewLoading: boolean @@ -48,60 +64,136 @@ export const WellPDFActionsButton = ({ isLoading: isPermissionsLoading, canManageAmp, canViewConfidential, + canViewAmpStaging, } = useAccessCapabilities() + const [reportType, setReportType] = useState('field-sheet') const [isGenerating, setIsGenerating] = useState(false) + const isChemistry = reportType === 'chemistry-report' + + const { + reportYear, + hasChemistry, + isLoading: isChemistryLoading, + fetchYearObservations, + fetchWaterLevels, + } = useWellChemistryReport({ + thingId: id, + // Only worth asking once the report is on offer at all. + enabled: canViewAmpStaging, + }) + + // A well with no chemistry on file still gets a report, marked as having no + // results — the same thing the exporter produces, and the honest answer to + // "what does this well's water look like". The note only warns what is coming. + const chemistryNote = + isChemistry && !isChemistryLoading && !hasChemistry + ? 'No water chemistry on file — the report will show no results' + : undefined + + // Waiting on the year is the one thing that has to hold the actions back, + // since acting early would report on the wrong one. + const isChemistryYearPending = isChemistry && isChemistryLoading + const previewDisabled = - isPreviewLoading || isPermissionsLoading || !canManageAmp + isPreviewLoading || + isPermissionsLoading || + !canManageAmp || + isChemistryYearPending const downloadDisabled = isDownloadLoading || isPermissionsLoading || !canManageAmp || - isGenerating + isGenerating || + isChemistryYearPending const handlePreview = () => { + if (isChemistry) { + // The chemistry exporter already renders a full preview; hand it the + // well and year so it opens on this report rather than an empty picker. + go({ + to: '/ocotillo/chemistry-report', + query: { thing_id: id, year: reportYear }, + type: 'push', + }) + return + } + go({ to: `/ocotillo/well/pdf-preview/${id}`, type: 'push' }) } + const handleDownloadFieldSheet = async (opts: IPdfOptions) => { + const filename = buildPdfFilename(well) + + const blob = await pdf( + + ).toBlob() + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf` + a.click() + URL.revokeObjectURL(url) + + return a.download + } + + const handleDownloadChemistryReport = async (year: number) => { + const elevationFt = ( + well?.current_location?.properties as + | { elevation?: number | null } + | undefined + )?.elevation + + const [yearObservations, waterLevels] = await Promise.all([ + fetchYearObservations(year), + fetchWaterLevels(year, { elevationFt }), + ]) + + return downloadChemistryReport({ + well, + contacts, + observations: yearObservations, + waterLevels, + year, + }) + } + const handleDownload = async (opts: IPdfOptions) => { if (!well?.id) return try { setIsGenerating(true) - const filename = buildPdfFilename(well) - - const blob = await pdf( - - ).toBlob() - - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf` - a.click() - URL.revokeObjectURL(url) + const filename = isChemistry + ? await handleDownloadChemistryReport(reportYear) + : await handleDownloadFieldSheet(opts) notify?.({ - message: 'PDF generated successfully', + message: isChemistry + ? `Chemistry report generated for ${reportYear}` + : 'PDF generated successfully', type: 'success', - description: a.download, + description: filename, }) } catch (error) { console.error(error) notify?.({ - message: 'PDF Generation Failed', + message: isChemistry + ? 'Chemistry report generation failed' + : 'PDF Generation Failed', type: 'error', }) } finally { @@ -109,14 +201,45 @@ export const WellPDFActionsButton = ({ } } + const downloadTooltip = isGenerating + ? 'Generating…' + : isChemistry + ? (chemistryNote ?? `Download chemistry report for ${reportYear}`) + : 'Download field sheet' + return (
+ +
+ {/* Wrapped so the tooltip still explains the button while it is + disabled — a disabled button emits no pointer events. */} + + + - - {isGenerating ? 'Generating…' : 'Download PDF'} - + {downloadTooltip}
) diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index ff3bb34b..5cfd441b 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1,3 +1,4 @@ +export * from './ChemistryReportDownload' export * from './ReportBugButton' export * from './WellPDFPreview' export * from './WellPDFDownload' diff --git a/src/components/pdf/chemistry/ChemistryReportPdf.tsx b/src/components/pdf/chemistry/ChemistryReportPdf.tsx new file mode 100644 index 00000000..1007ab1d --- /dev/null +++ b/src/components/pdf/chemistry/ChemistryReportPdf.tsx @@ -0,0 +1,892 @@ +import { Page, Text, View } from '@react-pdf/renderer' +import { useMemo } from 'react' +import type { ChemistryResult } from '@/hooks/useChemistryReportData' +import type { IContact, IWell } from '@/interfaces/ocotillo' +import { + type ChemistryResultRow, + type ChemistryStatus, + displayParameterName, + formatReportDate, + formatResultValue, + formatStandardLimit, + latestResultPerParameter, + pivotFieldParameters, + reportableResults, + resultStatus, + summarizeChemistry, + type WaterLevelReading, + waterLevelChangeFt, +} from '@/utils/chemistryReport' +import { formatContactAddress } from '@/utils/FormatAddress' +import { OcotilloDocument } from '../OcotilloDocument' +import { CHEM_REPORT_COLORS as c, chemReportStyles as s } from './styles' + +export type ChemistryReportSections = { + wellInformation: boolean + waterLevels: boolean + fieldParameters: boolean + chemistryResults: boolean + standardsComparison: boolean + howToRead: boolean +} + +export const CHEMISTRY_REPORT_DEFAULT_SECTIONS: ChemistryReportSections = { + wellInformation: true, + waterLevels: true, + fieldParameters: true, + chemistryResults: true, + standardsComparison: true, + howToRead: true, +} + +export const CHEMISTRY_REPORT_SECTION_LABELS: Record< + keyof ChemistryReportSections, + string +> = { + wellInformation: 'Well information & construction', + waterLevels: 'Water level measurements', + fieldParameters: 'Field parameters', + chemistryResults: 'Chemistry results', + standardsComparison: 'Drinking water standards & exceedances', + howToRead: 'How to read this report', +} + +type ChemistryReportPdfProps = { + well?: IWell + contacts?: readonly IContact[] + observations: readonly ChemistryResult[] + waterLevels?: readonly WaterLevelReading[] + year: number + sections?: ChemistryReportSections +} + +const SectionHead = ({ + title, + note, +}: { + title: string + note?: string | null +}) => ( + + {title} + {note ? {note} : null} + +) + +const Stat = ({ + label, + value, + note, + tone, +}: { + label: string + value: string | number + note: string + tone?: 'danger' | 'warning' +}) => ( + + {label} + + {String(value)} + + {note} + +) + +/** Four cells across, so a row of the grid is one line of the well's record. */ +const KvGrid = ({ + entries, +}: { + entries: { label: string; value: string | number | null | undefined }[] +}) => { + const perRow = 4 + const rows: (typeof entries)[] = [] + for (let index = 0; index < entries.length; index += perRow) { + rows.push(entries.slice(index, index + perRow)) + } + + return ( + + {rows.map((row, rowIndex) => ( + + {Array.from({ length: perRow }, (_, cellIndex) => { + const entry = row[cellIndex] + return ( + + {entry ? ( + <> + {entry.label} + + {entry.value == null || entry.value === '' + ? '—' + : String(entry.value)} + + + ) : null} + + ) + })} + + ))} + + ) +} + +const statusPillStyle = (kind: ChemistryStatus['kind']) => { + switch (kind) { + case 'above-mcl': + return s.pillDanger + case 'above-smcl': + return s.pillWarning + case 'below': + case 'not-detected': + return s.pillOk + default: + return s.pillNeutral + } +} + +const StatusPill = ({ status }: { status: ChemistryStatus }) => { + if (status.kind === 'none') { + return + } + + const pillStyle = statusPillStyle(status.kind) + + return ( + + {status.label} + + ) +} + +const Legend = () => ( + + {[ + { color: c.dangerTint, label: 'Above a health limit (MCL)' }, + { color: c.warningTint, label: 'Above a taste/odour guideline (SMCL)' }, + { color: c.okTint, label: 'Within the limit' }, + ].map((item) => ( + + + {item.label} + + ))} + ND — not detected + +) + +const CHEM_COLUMNS = { + parameter: { flex: 2.4 }, + result: { flex: 1.1 }, + unit: { flex: 0.8 }, + standard: { flex: 1 }, + type: { flex: 0.7 }, + status: { flex: 1.3 }, + measured: { flex: 1.1 }, +} as const + +const ChemistryTable = ({ + rows, + showStandards, +}: { + rows: readonly ChemistryResultRow[] + showStandards: boolean +}) => ( + + + Parameter + Your result + Unit + {showStandards ? ( + <> + Standard + Type + Status + + ) : null} + Measured + + + {rows.map((row, index) => { + const status = resultStatus(row) + const rowTint = + status.kind === 'above-mcl' + ? [s.trDanger] + : status.kind === 'above-smcl' + ? [s.trWarning] + : index % 2 === 1 + ? [s.trZebra] + : [] + + return ( + + + {displayParameterName(row.parameterName)} + + + {formatResultValue(row.value)} + + {row.unit ?? '—'} + {showStandards ? ( + <> + + {formatStandardLimit(row)} + + + {row.standard?.kind ?? '—'} + + + + + + ) : null} + + {formatReportDate(row.sampledOn)} + + + ) + })} + +) + +const WaterLevelTable = ({ + readings, +}: { + readings: readonly WaterLevelReading[] +}) => ( + + + Date + Depth to water + Water elevation + Method + + {readings.map((reading) => ( + + + {formatReportDate(reading.measuredOn)} + {reading.isPrior ? ' (prior)' : ''} + + + {reading.depthToWaterFt == null + ? '—' + : `${reading.depthToWaterFt.toFixed(1)} ft`} + + + {reading.waterElevationFt == null + ? '—' + : `${reading.waterElevationFt.toLocaleString('en-US')} ft`} + + + {reading.method} + + + ))} + +) + +const GLOSSARY_LEFT = [ + { + term: 'MCL (Maximum Contaminant Level)', + body: 'an enforceable federal health-based limit for public water systems. Private wells are not regulated, but the limit is the best available yardstick.', + }, + { + term: 'SMCL (Secondary MCL)', + body: 'a non-health limit covering taste, odour, colour, and staining. Exceeding it is a nuisance, not a health risk.', + }, + { + term: 'ND (Not detected)', + body: 'below what the instrument can measure. It does not mean the parameter is absent.', + }, + { + term: 'mg/L', + body: 'milligrams per litre, roughly one part per million.', + }, +] + +const GLOSSARY_RIGHT = [ + { + term: 'Ion balance', + body: 'a laboratory check that the positive and negative ions add up. A passing balance means the analysis is internally consistent.', + }, + { + term: 'Depth to water', + body: 'measured downward from the ground surface. Water elevation is the same measurement expressed as height above sea level, so a falling water table shows as a larger depth and a smaller elevation.', + }, + { + term: 'Limitations', + body: 'results describe the water on the day it was sampled, at the point it was sampled. Water quality changes with season, pumping, and household plumbing. This report does not certify water as safe to drink.', + }, +] + +export const ChemistryReportPdf = ({ + well, + contacts = [], + observations, + waterLevels = [], + year, + sections = CHEMISTRY_REPORT_DEFAULT_SECTIONS, +}: ChemistryReportPdfProps) => { + const summary = useMemo( + () => summarizeChemistry(observations), + [observations] + ) + const fieldTable = useMemo( + () => pivotFieldParameters(summary.fieldParameters), + [summary.fieldParameters] + ) + const latest = useMemo( + () => latestResultPerParameter(summary.labResults), + [summary.labResults] + ) + const reportable = useMemo( + () => reportableResults(latest.rows), + [latest.rows] + ) + const levelChange = useMemo( + () => waterLevelChangeFt(waterLevels), + [waterLevels] + ) + + const owner = contacts[0] + const ownerAddress = owner?.addresses?.[0] + ? formatContactAddress(owner.addresses[0]) + : null + const locationProperties = well?.current_location?.properties as + | { county?: string | null; elevation?: number | null } + | undefined + const coordinates = well?.current_location?.geometry?.coordinates as + | number[] + | undefined + const osePermit = well?.alternate_ids?.find( + (link) => + link.alternate_organization === 'NMOSE' && link.relation === 'OSEPOD' + )?.alternate_id + + const wellLabel = well?.name ?? 'Unknown well' + const hasSamples = summary.rows.length > 0 + const ionBalance = summary.rows.filter( + (row) => row.parameterName === 'Ion Balance' + ) + + return ( + + + {/* ---- Masthead ---- */} + + New Mexico Bureau of Geology & Mineral Resources · Aquifer Mapping + Program + + Annual Water Quality Report + + {`Reporting year ${year} · Well `} + {wellLabel} + {well?.site_name ? ` — ${well.site_name}` : ''} + + + + {owner ? ( + <> + {'Prepared for '} + {owner.name} + {', owner of record'} + + ) : ( + 'No owner of record on file' + )} + + + {[ + ownerAddress, + `Issued ${formatReportDate(new Date().toISOString())}`, + ] + .filter(Boolean) + .join(' · ')} + + + + + + + {`This report summarizes everything on file for your well for the ${year} calendar year: how the well is built, what the water was tested for, and how those results compare to drinking water standards. It is provided as a courtesy and is not a certification that the water is safe to drink.`} + + + {/* ---- At a glance ---- */} + + + + formatReportDate(date)) + .join(' & ') + : `${formatReportDate(summary.sampleDates[0])} – ${formatReportDate(summary.sampleDates[summary.sampleDates.length - 1])}` + } + /> + + displayParameterName(row.parameterName)) + .join(', ') + : 'None' + } + tone={summary.mclExceedances.length ? 'danger' : undefined} + /> + displayParameterName(row.parameterName)) + .join(', ') + : 'None' + } + tone={summary.smclExceedances.length ? 'warning' : undefined} + /> + 0 ? '+' : ''}${levelChange.changeFt.toFixed(1)} ft` + : '—' + } + note={ + levelChange + ? `vs. ${formatReportDate(levelChange.comparedTo)}` + : 'Needs two readings' + } + /> + + + + {/* ---- Exceedance callouts ---- */} + {sections.standardsComparison && summary.mclExceedances.length > 0 ? ( + + + {summary.mclExceedances.length === 1 + ? 'One result was above a federal health limit' + : `${summary.mclExceedances.length} results were above a federal health limit`} + + {summary.mclExceedances.map((row) => ( + + + {`${displayParameterName(row.parameterName)} — ${formatResultValue(row.value)} ${row.unit ?? ''}`} + + {` (limit ${row.standard?.limit} ${row.standard?.unit}, measured ${formatReportDate(row.sampledOn)}).`} + {row.standard?.note ? ` ${row.standard.note}` : ''} + + ))} + + · Consider a confirmation sample before making treatment + decisions. + + + · The NM Environment Department Drinking Water Bureau advises + private well owners at (505) 476-8620. + + + ) : null} + + {sections.standardsComparison && summary.smclExceedances.length > 0 ? ( + + + {`${summary.smclExceedances.length} result${summary.smclExceedances.length === 1 ? '' : 's'} above a taste, odour, or staining guideline`} + + + {summary.smclExceedances + .map( + (row) => + `${displayParameterName(row.parameterName)} ${formatResultValue(row.value)} ${row.unit ?? ''}` + ) + .join('; ')} + . Secondary standards are not health limits — they describe how + the water looks, tastes, and smells. + + + ) : null} + + {/* ---- Well information ---- */} + {sections.wellInformation ? ( + + + + + ) : null} + + {/* ---- Water levels ---- */} + {sections.waterLevels ? ( + + + {waterLevels.length ? ( + + ) : ( + + No water level measurements are on file for this well. + + )} + {waterLevels.length ? ( + + Depths are measured from the top of casing. Water elevation is + the land surface elevation less the depth to water, and is shown + only where a surveyed elevation is on file. + + ) : null} + + ) : null} + + {/* ---- Field parameters (page 2) ---- */} + {sections.fieldParameters ? ( + + + {fieldTable.rows.length ? ( + + + Parameter + {fieldTable.dates.map((date) => ( + + {formatReportDate(date)} + + ))} + Unit + + {fieldTable.rows.map((row, index) => ( + + + {displayParameterName(row.parameterName)} + + {fieldTable.dates.map((date) => ( + + {row.valuesByDate[date] ?? '—'} + + ))} + {row.unit ?? '—'} + + ))} + + ) : ( + + No field parameters were recorded for this period. + + )} + + ) : null} + + {/* ---- Chemistry results ---- */} + {sections.chemistryResults ? ( + + + {reportable.rows.length ? ( + <> + + + + {[ + reportable.omittedCount > 0 + ? `${reportable.omittedCount} further parameters have no drinking water standard to compare against; the full list is on file.` + : null, + summary.sampleDates.length > 1 + ? `Each parameter is shown at its most recent ${year} value, across ${summary.sampleDates.length} sampling visits.` + : null, + ] + .filter(Boolean) + .join(' ')} + + + ) : ( + + {hasSamples + ? 'No laboratory results were recorded for this period.' + : `No water chemistry was collected at this well during ${year}.`} + + )} + + ) : null} + + {/* ---- Sampling notes and glossary (page 3) ---- */} + {sections.howToRead ? ( + + {ionBalance.length ? ( + + + + + + Collected + + + Ion balance + + Check + + Parameters in this sample + + + {ionBalance.map((row) => { + const passes = row.value != null && Math.abs(row.value) <= 5 + const day = row.sampledOn.slice(0, 10) + const inSample = summary.rows.filter( + (other) => other.sampledOn.slice(0, 10) === day + ).length + + return ( + + + {formatReportDate(row.sampledOn)} + + + {`${formatResultValue(row.value)} ${row.unit ?? ''}`} + + + + + {passes ? 'Pass' : 'Review'} + + + + + {`${inSample} results`} + + + ) + })} + + + A balance within ±5% means the positive and negative ions + measured in the sample add up, so the analysis is internally + consistent. + + + ) : null} + + + + + + {GLOSSARY_LEFT.map((entry) => ( + + {entry.term} + {` — ${entry.body}`} + + ))} + + + {GLOSSARY_RIGHT.map((entry) => ( + + {entry.term} + {` — ${entry.body}`} + + ))} + + + Questions, or want more data? + + { + ' Email aquifermapping@nmt.edu or call (575) 835-5327. You can request the complete record for your well at any time.' + } + + + + + + ) : null} + + + + {'Questions: aquifermapping@nmt.edu · (575) 835-5327'} + + + `${wellLabel} · Annual Water Quality Report ${year} · Page ${pageNumber} of ${totalPages}` + } + /> + + + + ) +} diff --git a/src/components/pdf/chemistry/downloadChemistryReport.tsx b/src/components/pdf/chemistry/downloadChemistryReport.tsx new file mode 100644 index 00000000..98c2856b --- /dev/null +++ b/src/components/pdf/chemistry/downloadChemistryReport.tsx @@ -0,0 +1,53 @@ +import { pdf } from '@react-pdf/renderer' +import type { ChemistryResult } from '@/hooks/useChemistryReportData' +import type { IContact, IWell } from '@/interfaces/ocotillo' +import { + buildChemistryReportFilename, + type WaterLevelReading, +} from '@/utils/chemistryReport' +import { + ChemistryReportPdf, + type ChemistryReportSections, +} from './ChemistryReportPdf' + +/** + * Renders the report and hands it to the browser as a download. Returns the + * filename so the caller can name it in a notification. + */ +export const downloadChemistryReport = async ({ + well, + contacts, + observations, + waterLevels, + year, + sections, +}: { + well: IWell + contacts: readonly IContact[] + observations: readonly ChemistryResult[] + waterLevels?: readonly WaterLevelReading[] + year: number + sections?: ChemistryReportSections +}): Promise => { + const filename = buildChemistryReportFilename(well, year) + + const blob = await pdf( + + ).toBlob() + + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) + + return filename +} diff --git a/src/components/pdf/chemistry/index.ts b/src/components/pdf/chemistry/index.ts new file mode 100644 index 00000000..a814ce78 --- /dev/null +++ b/src/components/pdf/chemistry/index.ts @@ -0,0 +1,3 @@ +export * from './ChemistryReportPdf' +export * from './downloadChemistryReport' +export * from './styles' diff --git a/src/components/pdf/chemistry/styles.ts b/src/components/pdf/chemistry/styles.ts new file mode 100644 index 00000000..12adb3af --- /dev/null +++ b/src/components/pdf/chemistry/styles.ts @@ -0,0 +1,306 @@ +import { StyleSheet } from '@react-pdf/renderer' + +/** + * Print palette and type scale for the owner-facing chemistry report. + * + * No style here sets `lineHeight`. react-pdf 4.4.0 renders any explicit value + * -- 1.0 and 1.55 alike -- with roughly double the leading it should, which + * double-spaced every wrapped paragraph in the report. Its default metrics are + * correct, so leading is left alone. + * + * The report is read by well owners, not by staff, so the design does the + * triage: a navy masthead, teal section rules, and results that carry their own + * verdict as a tinted row and a status pill rather than a number the reader has + * to look up. Numeric columns are monospaced so decimal points line up down a + * column and 0.012 cannot be mistaken for 0.12 at a glance. + * + * The PDF has no dark mode, so these are literals rather than variables. + */ +export const CHEM_REPORT_COLORS = { + navy: '#1c3f66', + teal: '#1b6d8f', + foreground: '#16202c', + muted: '#6b7785', + faint: '#96a1ad', + border: '#dfe3e8', + borderStrong: '#c8ced6', + zebra: '#f7f9fb', + + danger: '#c0392b', + dangerTint: '#fdf1f0', + dangerBorder: '#e8b4ae', + + warning: '#b5651d', + warningTint: '#fdf6ef', + warningBorder: '#e8cfae', + + ok: '#2f7a4d', + okTint: '#f1f8f3', + okBorder: '#b7d8c2', + + infoTint: '#f0f6fa', + infoBorder: '#bcd6e5', +} as const + +const MONO = 'Courier' + +export const chemReportStyles = StyleSheet.create({ + page: { + flexDirection: 'column', + backgroundColor: '#ffffff', + paddingTop: 38, + paddingBottom: 58, + paddingHorizontal: 40, + color: CHEM_REPORT_COLORS.foreground, + fontSize: 8.5, + }, + + // ---- Masthead ----------------------------------------------------------- + org: { + fontSize: 7.5, + fontWeight: 'bold', + color: CHEM_REPORT_COLORS.navy, + textTransform: 'uppercase', + letterSpacing: 1.1, + }, + reportTitle: { + fontSize: 21, + fontWeight: 'bold', + color: CHEM_REPORT_COLORS.navy, + marginTop: 7, + }, + reportSubtitle: { + fontSize: 10, + color: CHEM_REPORT_COLORS.muted, + marginTop: 5, + }, + reportSubtitleStrong: { color: CHEM_REPORT_COLORS.foreground }, + ownerBlock: { marginTop: 12 }, + ownerName: { fontWeight: 'bold' }, + ownerMeta: { fontSize: 7.5, color: CHEM_REPORT_COLORS.muted, marginTop: 2 }, + mastheadRule: { + borderBottomWidth: 0.75, + borderBottomColor: CHEM_REPORT_COLORS.borderStrong, + marginTop: 14, + marginBottom: 14, + }, + lede: { marginBottom: 16 }, + + // ---- Section headings --------------------------------------------------- + section: { marginBottom: 13 }, + sectionHeadRow: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + marginBottom: 7, + }, + sectionHeading: { + fontSize: 8.5, + fontWeight: 'bold', + color: CHEM_REPORT_COLORS.teal, + textTransform: 'uppercase', + letterSpacing: 1, + }, + sectionNote: { fontSize: 7.5, color: CHEM_REPORT_COLORS.muted }, + + // ---- At a glance -------------------------------------------------------- + statRow: { flexDirection: 'row', gap: 7 }, + stat: { + flex: 1, + borderWidth: 0.75, + borderColor: CHEM_REPORT_COLORS.border, + borderRadius: 3, + paddingVertical: 8, + paddingHorizontal: 8, + }, + statLabel: { + fontSize: 6.5, + color: CHEM_REPORT_COLORS.muted, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + statValue: { fontSize: 17, fontWeight: 'bold', marginTop: 5 }, + statValueDanger: { color: CHEM_REPORT_COLORS.danger }, + statValueWarning: { color: CHEM_REPORT_COLORS.warning }, + statNote: { fontSize: 7, color: CHEM_REPORT_COLORS.muted, marginTop: 4 }, + + // ---- Callouts ----------------------------------------------------------- + callout: { + borderLeftWidth: 2.5, + borderLeftColor: CHEM_REPORT_COLORS.danger, + backgroundColor: CHEM_REPORT_COLORS.dangerTint, + paddingVertical: 9, + paddingHorizontal: 11, + marginBottom: 16, + }, + calloutWarn: { + borderLeftColor: CHEM_REPORT_COLORS.warning, + backgroundColor: CHEM_REPORT_COLORS.warningTint, + }, + calloutInfo: { + borderLeftColor: CHEM_REPORT_COLORS.teal, + backgroundColor: CHEM_REPORT_COLORS.infoTint, + }, + calloutTitle: { fontWeight: 'bold', marginBottom: 5 }, + calloutBody: {}, + calloutBullet: { marginTop: 4, paddingLeft: 10 }, + + // ---- Key/value grid ---------------------------------------------------- + kvTable: { + borderWidth: 0.75, + borderColor: CHEM_REPORT_COLORS.border, + borderRadius: 3, + }, + kvRow: { + flexDirection: 'row', + borderBottomWidth: 0.75, + borderBottomColor: CHEM_REPORT_COLORS.border, + }, + kvRowLast: { borderBottomWidth: 0 }, + kvCell: { + flex: 1, + paddingVertical: 7, + paddingHorizontal: 9, + borderRightWidth: 0.75, + borderRightColor: CHEM_REPORT_COLORS.border, + }, + kvCellLast: { borderRightWidth: 0 }, + kvLabel: { + fontSize: 6.5, + color: CHEM_REPORT_COLORS.muted, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + kvValue: { marginTop: 3 }, + + // ---- Tables ------------------------------------------------------------- + table: { marginTop: 2 }, + th: { + flexDirection: 'row', + borderBottomWidth: 0.75, + borderBottomColor: CHEM_REPORT_COLORS.borderStrong, + paddingBottom: 4, + }, + thText: { + fontSize: 6.5, + color: CHEM_REPORT_COLORS.muted, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + tr: { + flexDirection: 'row', + alignItems: 'center', + borderBottomWidth: 0.5, + borderBottomColor: CHEM_REPORT_COLORS.border, + paddingVertical: 3.5, + }, + trZebra: { backgroundColor: CHEM_REPORT_COLORS.zebra }, + trDanger: { backgroundColor: CHEM_REPORT_COLORS.dangerTint }, + trWarning: { backgroundColor: CHEM_REPORT_COLORS.warningTint }, + trMuted: { color: CHEM_REPORT_COLORS.faint }, + td: { paddingHorizontal: 4 }, + tdMono: { fontFamily: MONO, fontSize: 8 }, + tdStrong: { fontWeight: 'bold' }, + tdNoStandard: { color: CHEM_REPORT_COLORS.faint }, + + // ---- Status pills ------------------------------------------------------- + pill: { + borderWidth: 0.75, + borderRadius: 7, + paddingVertical: 2, + paddingHorizontal: 5, + alignSelf: 'flex-start', + }, + pillText: { fontSize: 6.5 }, + pillDanger: { + borderColor: CHEM_REPORT_COLORS.dangerBorder, + backgroundColor: '#ffffff', + color: CHEM_REPORT_COLORS.danger, + }, + pillWarning: { + borderColor: CHEM_REPORT_COLORS.warningBorder, + backgroundColor: '#ffffff', + color: CHEM_REPORT_COLORS.warning, + }, + pillOk: { + borderColor: CHEM_REPORT_COLORS.okBorder, + backgroundColor: '#ffffff', + color: CHEM_REPORT_COLORS.ok, + }, + pillNeutral: { + borderColor: CHEM_REPORT_COLORS.border, + backgroundColor: '#ffffff', + color: CHEM_REPORT_COLORS.muted, + }, + ndBadge: { + borderWidth: 0.75, + borderColor: CHEM_REPORT_COLORS.border, + borderRadius: 6, + paddingVertical: 1, + paddingHorizontal: 3, + marginLeft: 4, + fontSize: 6, + color: CHEM_REPORT_COLORS.muted, + }, + + // ---- Legend and footnotes ---------------------------------------------- + legendRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginTop: 7, + }, + legendItem: { flexDirection: 'row', alignItems: 'center', gap: 3 }, + legendSwatch: { width: 7, height: 5, borderRadius: 1 }, + legendText: { fontSize: 6.5, color: CHEM_REPORT_COLORS.muted }, + footnote: { + fontSize: 7, + fontStyle: 'italic', + color: CHEM_REPORT_COLORS.muted, + marginTop: 7, + }, + emptyNote: { fontSize: 8, color: CHEM_REPORT_COLORS.muted }, + + // ---- Placeholder for a chart the export cannot draw -------------------- + chartSlot: { + borderWidth: 0.75, + borderColor: CHEM_REPORT_COLORS.border, + borderRadius: 3, + backgroundColor: CHEM_REPORT_COLORS.zebra, + height: 118, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 14, + }, + chartSlotText: { + fontSize: 7, + fontStyle: 'italic', + color: CHEM_REPORT_COLORS.faint, + textAlign: 'center', + }, + + // ---- Two-column glossary ---------------------------------------------- + glossaryRow: { flexDirection: 'row', gap: 18 }, + glossaryColumn: { flex: 1 }, + glossaryEntry: { marginBottom: 6 }, + glossaryTerm: { fontWeight: 'bold' }, + + // ---- Footer ------------------------------------------------------------- + footer: { + position: 'absolute', + bottom: 26, + left: 40, + right: 40, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + borderTopWidth: 0.75, + borderTopColor: CHEM_REPORT_COLORS.border, + paddingTop: 7, + }, + footerText: { fontSize: 6.5, color: CHEM_REPORT_COLORS.muted }, + footerContact: { + fontSize: 6.5, + color: CHEM_REPORT_COLORS.muted, + }, +}) diff --git a/src/components/pdf/index.ts b/src/components/pdf/index.ts index 3609a8ac..68fd7f88 100644 --- a/src/components/pdf/index.ts +++ b/src/components/pdf/index.ts @@ -1,4 +1,5 @@ export * from './OcotilloDocument' +export * from './chemistry' export * from './well' export * from './AdditionalInformation' export * from './CoreInformation' diff --git a/src/constants/drinkingWaterStandards.ts b/src/constants/drinkingWaterStandards.ts new file mode 100644 index 00000000..5ec8937d --- /dev/null +++ b/src/constants/drinkingWaterStandards.ts @@ -0,0 +1,110 @@ +import type { ParameterName } from '@/generated/types.gen' + +/** + * Federal drinking water standards used to flag owner-facing chemistry + * reports. + * + * - MCL (Maximum Contaminant Level) is an enforceable health-based limit. + * - SMCL (Secondary MCL) is a non-enforceable taste, odor, or staining + * guideline. + * + * Values are EPA National Primary/Secondary Drinking Water Regulations, in + * mg/L unless noted. Parameters absent from this table are reported without a + * comparison rather than being reported as passing. + */ +export type StandardKind = 'MCL' | 'SMCL' + +export type DrinkingWaterStandard = { + kind: StandardKind + /** Threshold in `unit`. A result strictly above this is an exceedance. */ + limit: number + unit: string + /** Plain-language note printed under the exceedance callout. */ + note?: string +} + +export const DRINKING_WATER_STANDARDS: Partial< + Record +> = { + Arsenic: { + kind: 'MCL', + limit: 0.01, + unit: 'mg/L', + note: 'Arsenic occurs naturally in New Mexico groundwater. Long-term consumption above the limit is associated with health risk.', + }, + Barium: { kind: 'MCL', limit: 2, unit: 'mg/L' }, + Antimony: { kind: 'MCL', limit: 0.006, unit: 'mg/L' }, + Beryllium: { kind: 'MCL', limit: 0.004, unit: 'mg/L' }, + Cadmium: { kind: 'MCL', limit: 0.005, unit: 'mg/L' }, + Chromium: { kind: 'MCL', limit: 0.1, unit: 'mg/L' }, + Cyanide: { kind: 'MCL', limit: 0.2, unit: 'mg/L' }, + Fluoride: { + kind: 'MCL', + limit: 4, + unit: 'mg/L', + note: 'Fluoride above 2 mg/L can stain children’s teeth; above 4 mg/L is a health limit.', + }, + Mercury: { kind: 'MCL', limit: 0.002, unit: 'mg/L' }, + 'Nitrate (as N)': { + kind: 'MCL', + limit: 10, + unit: 'mg/L', + note: 'Nitrate above the limit is an immediate risk to infants under six months and to pregnant people.', + }, + 'Nitrite (as N)': { kind: 'MCL', limit: 1, unit: 'mg/L' }, + Selenium: { kind: 'MCL', limit: 0.05, unit: 'mg/L' }, + Thallium: { kind: 'MCL', limit: 0.002, unit: 'mg/L' }, + Lead: { + kind: 'MCL', + limit: 0.015, + unit: 'mg/L', + note: 'Lead in a private well is usually contributed by household plumbing rather than by the aquifer.', + }, + 'Uranium (total, by ICP-MS)': { kind: 'MCL', limit: 0.03, unit: 'mg/L' }, + + Aluminum: { kind: 'SMCL', limit: 0.2, unit: 'mg/L' }, + Chloride: { kind: 'SMCL', limit: 250, unit: 'mg/L' }, + Copper: { kind: 'SMCL', limit: 1, unit: 'mg/L' }, + Iron: { kind: 'SMCL', limit: 0.3, unit: 'mg/L' }, + Manganese: { kind: 'SMCL', limit: 0.05, unit: 'mg/L' }, + Silver: { kind: 'SMCL', limit: 0.1, unit: 'mg/L' }, + Sulfate: { kind: 'SMCL', limit: 250, unit: 'mg/L' }, + 'Total Dissolved Solids': { kind: 'SMCL', limit: 500, unit: 'mg/L' }, + Zinc: { kind: 'SMCL', limit: 5, unit: 'mg/L' }, +} + +export const getDrinkingWaterStandard = ( + parameterName?: string | null +): DrinkingWaterStandard | undefined => + parameterName + ? DRINKING_WATER_STANDARDS[parameterName as ParameterName] + : undefined + +export type StandardComparison = { + standard?: DrinkingWaterStandard + /** True only when a standard exists and the value is strictly above it. */ + exceeds: boolean +} + +/** + * Compares a result against its standard. Units are not converted: a result + * reported in a unit other than the standard's is treated as not comparable, + * so a mg/L limit is never silently applied to a µg/L number. + */ +export const compareToStandard = ( + parameterName: string | null | undefined, + value: number | null | undefined, + unit: string | null | undefined +): StandardComparison => { + const standard = getDrinkingWaterStandard(parameterName) + + if (!standard || value == null || Number.isNaN(value)) { + return { standard, exceeds: false } + } + + if (unit && unit !== standard.unit) { + return { standard, exceeds: false } + } + + return { standard, exceeds: value > standard.limit } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index b39fcf72..830dc00f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useAccessCapabilities' export * from './useSearchHistory' export * from './useAll' export * from './useAllNotes' +export * from './useChemistryReportData' export * from './useDebounce' export * from './useElevation' export * from './useLayer' @@ -21,4 +22,5 @@ export * from './useViewportBbox' export * from './useSearchModalState' export * from './useSidebarPanelSync' export * from './useWellDetails' +export * from './useWellChemistryReport' export * from './useContainerMinWidth' diff --git a/src/hooks/useChemistryReportData.ts b/src/hooks/useChemistryReportData.ts new file mode 100644 index 00000000..8a2ac74d --- /dev/null +++ b/src/hooks/useChemistryReportData.ts @@ -0,0 +1,134 @@ +import { useList, useOne } from '@refinedev/core' +import { useMemo } from 'react' +import type { IContact, IWell } from '@/interfaces/ocotillo' +import { + CHEMISTRY_REPORT_PAGE_SIZE, + chemistryReportYearParams, + sortChemistryResults, + toWaterLevelReadings, + type WaterLevelObservation, +} from '@/utils/chemistryReport' + +/** Which legacy chemistry table a result came from. */ +export type ChemistryResultKind = + | 'major' + | 'minor' + | 'radionuclide' + | 'field' + | 'unknown' + +/** + * One analyte result from `chemistry/results`. Hand-written rather than taken + * from `types.gen`: the generated types cover the refactored + * `observation/water-chemistry` endpoint, which holds no water chemistry, and + * this response is flat -- the parameter name is on the row instead of in a + * nested parameter record. + */ +export type ChemistryResult = { + id: string + thing_id: number + station_name?: string | null + sample_id?: number | null + parameter_name: string + value: number | null + unit: string | null + observation_datetime: string + result_kind: ChemistryResultKind +} + +/** + * Everything the chemistry report needs for one well and one reporting + * period. Chemistry comes from the legacy NMA tables via `chemistry/results`; + * the refactored observation endpoint holds none. The period is inclusive of Jan 1 and exclusive of Jan 1 of the + * following year, which is how the API's start_time/end_time filter behaves. + */ +export const useChemistryReportData = ({ + thingId, + year, +}: { + thingId: string | number | undefined + year: number +}) => { + const enabled = Boolean(thingId) + + const { result: well, query: wellQuery } = useOne({ + resource: 'thing-well', + id: thingId, + queryOptions: { enabled }, + }) + + const { result: contactResult, query: contactQuery } = useList({ + resource: 'contact', + dataProviderName: 'ocotillo', + meta: { params: { thing_id: thingId } }, + queryOptions: { enabled }, + }) + + const { result: observationResult, query: observationQuery } = + useList({ + resource: 'chemistry/results', + dataProviderName: 'ocotillo', + pagination: { + currentPage: 1, + pageSize: CHEMISTRY_REPORT_PAGE_SIZE, + mode: 'server', + }, + meta: { + params: { + thing_id: thingId, + ...chemistryReportYearParams(year), + }, + }, + queryOptions: { enabled }, + }) + + const { result: waterLevelResult, query: waterLevelQuery } = + useList({ + resource: 'observation/groundwater-level', + dataProviderName: 'ocotillo', + pagination: { + currentPage: 1, + pageSize: CHEMISTRY_REPORT_PAGE_SIZE, + mode: 'server', + }, + meta: { + params: { thing_id: thingId, ...chemistryReportYearParams(year) }, + }, + queryOptions: { enabled }, + }) + + const observations = useMemo( + () => sortChemistryResults(observationResult?.data ?? []), + [observationResult?.data] + ) + + const elevationFt = ( + (well as IWell | undefined)?.current_location?.properties as + | { elevation?: number | null } + | undefined + )?.elevation + + const waterLevels = useMemo( + () => + toWaterLevelReadings(waterLevelResult?.data ?? [], { + elevationFt, + }), + [waterLevelResult?.data, elevationFt] + ) + + const isLoading = + wellQuery.isLoading || + contactQuery.isLoading || + observationQuery.isLoading || + waterLevelQuery.isLoading + + return { + well: well as IWell | undefined, + contacts: contactResult?.data ?? [], + observations, + waterLevels, + isLoading: enabled ? isLoading : false, + isError: + wellQuery.isError || contactQuery.isError || observationQuery.isError, + } +} diff --git a/src/hooks/useWellChemistryReport.ts b/src/hooks/useWellChemistryReport.ts new file mode 100644 index 00000000..e406312a --- /dev/null +++ b/src/hooks/useWellChemistryReport.ts @@ -0,0 +1,140 @@ +import { useDataProvider, useList } from '@refinedev/core' +import { useCallback, useMemo } from 'react' +import { + CHEMISTRY_REPORT_PAGE_SIZE, + chemistryReportYearOf, + chemistryReportYearParams, + sortChemistryResults, + toWaterLevelReadings, + type WaterLevelObservation, +} from '@/utils/chemistryReport' +import type { ChemistryResult } from './useChemistryReportData' + +const CHEMISTRY_RESOURCE = 'chemistry/results' +const WATER_LEVEL_RESOURCE = 'observation/groundwater-level' + +/** + * Which year of chemistry a well's report should cover, and a way to pull it. + * + * Reads `chemistry/results`, which serves the legacy NMA chemistry tables. The + * refactored `observation/water-chemistry` endpoint holds no water chemistry at + * all, so a report built on it came back empty for every well. + * + * The report covers one calendar year, and the year worth reporting on is the + * most recent one sampled: a well last sampled in 2024 would otherwise produce + * an empty report for the current year. One row is enough to find it. + * + * A well with nothing on file still gets a year — the current one — because a + * report that says the well has no results on it is a legitimate thing to hand + * an owner, and is what the chemistry exporter already produces. `hasChemistry` + * is there to say so up front, not to block the report. + * + * The year's results are left until `fetchYearObservations` is called, since + * most visits to a well page are not after a chemistry report. + */ +export const useWellChemistryReport = ({ + thingId, + enabled = true, +}: { + thingId: string | number | undefined + enabled?: boolean +}) => { + const dataProvider = useDataProvider() + const ocotilloDataProvider = useMemo( + () => dataProvider('ocotillo'), + [dataProvider] + ) + + const { result, query } = useList({ + resource: CHEMISTRY_RESOURCE, + dataProviderName: 'ocotillo', + pagination: { currentPage: 1, pageSize: 1, mode: 'server' }, + sorters: [{ field: 'observation_datetime', order: 'desc' }], + meta: { params: { thing_id: thingId } }, + queryOptions: { + enabled: enabled && Boolean(thingId), + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }, + }) + + const latestSampledYear = chemistryReportYearOf( + result?.data?.[0]?.observation_datetime + ) + + const fetchYearObservations = useCallback( + async (year: number) => { + if (thingId == null) return [] + + const params = { thing_id: thingId, ...chemistryReportYearParams(year) } + const collected: ChemistryResult[] = [] + let currentPage = 1 + + while (true) { + const page = await ocotilloDataProvider.getList({ + resource: CHEMISTRY_RESOURCE, + pagination: { currentPage, pageSize: CHEMISTRY_REPORT_PAGE_SIZE }, + meta: { params }, + }) + + collected.push(...(page.data as ChemistryResult[])) + + if (page.data.length === 0 || collected.length >= page.total) break + currentPage += 1 + } + + return sortChemistryResults(collected) + }, + [ocotilloDataProvider, thingId] + ) + + /** + * The year's water level readings, plus the newest reading from before the + * year so the report can say which direction the water table moved. A single + * year in isolation has nothing to compare against. + */ + const fetchWaterLevels = useCallback( + async (year: number, { elevationFt }: { elevationFt?: number | null }) => { + if (thingId == null) return [] + + const window = chemistryReportYearParams(year) + + const [inYear, prior] = await Promise.all([ + ocotilloDataProvider.getList({ + resource: WATER_LEVEL_RESOURCE, + pagination: { currentPage: 1, pageSize: CHEMISTRY_REPORT_PAGE_SIZE }, + meta: { params: { thing_id: thingId, ...window } }, + }), + ocotilloDataProvider.getList({ + resource: WATER_LEVEL_RESOURCE, + pagination: { currentPage: 1, pageSize: 1 }, + sorters: [{ field: 'observation_datetime', order: 'desc' }], + meta: { + params: { thing_id: thingId, end_time: window.start_time }, + }, + }), + ]) + + const readings = toWaterLevelReadings( + inYear.data as WaterLevelObservation[], + { elevationFt } + ) + const priorReadings = toWaterLevelReadings( + prior.data as WaterLevelObservation[], + { elevationFt } + ).map((reading) => ({ ...reading, isPrior: true })) + + return [...readings, ...priorReadings] + }, + [ocotilloDataProvider, thingId] + ) + + return { + reportYear: latestSampledYear ?? new Date().getFullYear(), + latestSampledYear, + hasChemistry: latestSampledYear != null, + isLoading: enabled && Boolean(thingId) ? query.isLoading : false, + fetchYearObservations, + fetchWaterLevels, + } +} diff --git a/src/pages/ocotillo/chemistry-report/export.tsx b/src/pages/ocotillo/chemistry-report/export.tsx new file mode 100644 index 00000000..756db23d --- /dev/null +++ b/src/pages/ocotillo/chemistry-report/export.tsx @@ -0,0 +1,228 @@ +import { + Alert, + Autocomplete, + Box, + Checkbox, + FormControlLabel, + MenuItem, + Paper, + Skeleton, + Stack, + TextField, + Typography, +} from '@mui/material' +import Grid from '@mui/material/Grid2' +import { PDFViewer } from '@react-pdf/renderer' +import { useOne } from '@refinedev/core' +import { useAutocomplete } from '@refinedev/mui' +import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' +import { ChemistryReportDownloadButton } from '@/components/Button' +import { OcotilloPageTitle } from '@/components/OcotilloPageHeader' +import { + CHEMISTRY_REPORT_DEFAULT_SECTIONS, + CHEMISTRY_REPORT_SECTION_LABELS, + ChemistryReportPdf, + type ChemistryReportSections, +} from '@/components/pdf/chemistry' +import { useChemistryReportData, useDebounce } from '@/hooks' +import type { IWell } from '@/interfaces/ocotillo' + +/** + * Reporting periods offered in the picker: this year and the four before it, + * plus whatever year was linked to. A well last sampled outside that window + * still has to be selectable, or arriving from its details page would land on + * a year the picker cannot show. + */ +const buildYearOptions = (linkedYear?: number): number[] => { + const current = new Date().getFullYear() + const years = Array.from({ length: 5 }, (_, index) => current - index) + if (linkedYear && !years.includes(linkedYear)) years.push(linkedYear) + return years.sort((a, b) => b - a) +} + +const parseYearParam = (value: string | null): number | undefined => { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 1900 ? parsed : undefined +} + +export const ChemistryReportExport = () => { + // The well details page links here with the report it wants already chosen. + const [searchParams] = useSearchParams() + const linkedThingId = searchParams.get('thing_id') + const linkedYear = parseYearParam(searchParams.get('year')) + + const yearOptions = useMemo(() => buildYearOptions(linkedYear), [linkedYear]) + const [selectedWell, setSelectedWell] = useState(null) + const [year, setYear] = useState(linkedYear ?? yearOptions[0]) + + const { result: linkedWell } = useOne({ + resource: 'thing-well', + id: linkedThingId ?? undefined, + queryOptions: { enabled: Boolean(linkedThingId) }, + }) + + useEffect(() => { + // Only seeds the picker — once the user changes it, this stops applying. + if (linkedWell && !selectedWell) setSelectedWell(linkedWell as IWell) + }, [linkedWell, selectedWell]) + const [sections, setSections] = useState( + CHEMISTRY_REPORT_DEFAULT_SECTIONS + ) + + const [wellSearch, setWellSearch] = useState('') + const debouncedWellSearch = useDebounce(wellSearch, 300) + + // The API filters wells by the `name_contains` query param rather than by a + // Refine filter, so the search term is threaded through meta.params — the + // same shape the Wells list uses. + const { autocompleteProps } = useAutocomplete({ + resource: 'thing/water-well', + dataProviderName: 'ocotillo', + meta: { + params: { + include_contacts: true, + ...(debouncedWellSearch ? { name_contains: debouncedWellSearch } : {}), + }, + }, + }) + + const { well, contacts, observations, waterLevels, isLoading, isError } = + useChemistryReportData({ thingId: selectedWell?.id, year }) + + const toggleSection = (key: keyof ChemistryReportSections) => + setSections((previous) => ({ ...previous, [key]: !previous[key] })) + + const isReady = Boolean(selectedWell) && !isLoading && !isError + + return ( + + + + + + + Generate an owner-facing annual water quality report for a single well. + Multi-well runs, delivery, and scheduling are not implemented yet. + + + + + + setSelectedWell(newValue)} + getOptionKey={(option) => option.id} + getOptionLabel={(option) => `${option.name} (${option.id})`} + isOptionEqualToValue={(option, value) => option.id === value?.id} + inputValue={wellSearch} + onInputChange={(_, newInput) => setWellSearch(newInput)} + filterOptions={(options) => options} + renderInput={(params) => ( + + )} + /> + + + setYear(Number(event.target.value))} + > + {yearOptions.map((option) => ( + + {`Calendar year ${option}`} + + ))} + + + + + + Sections + + + {( + Object.keys( + CHEMISTRY_REPORT_SECTION_LABELS + ) as (keyof ChemistryReportSections)[] + ).map((key) => ( + toggleSection(key)} + /> + } + label={ + + {CHEMISTRY_REPORT_SECTION_LABELS[key]} + + } + /> + ))} + + + + + + + {isError ? ( + + Could not load chemistry data for this well. + + ) : null} + + {selectedWell && !isLoading && observations.length === 0 ? ( + + {`No water chemistry is on file for ${selectedWell.name} in ${year}. The report still generates, marked as having no results.`} + + ) : null} + + + {!selectedWell ? ( + + + Select a well to preview its report. + + + ) : isLoading ? ( + + ) : ( + + + + )} + + + ) +} diff --git a/src/pages/ocotillo/chemistry-report/index.tsx b/src/pages/ocotillo/chemistry-report/index.tsx new file mode 100644 index 00000000..dfbc7a6a --- /dev/null +++ b/src/pages/ocotillo/chemistry-report/index.tsx @@ -0,0 +1 @@ +export * from './export' diff --git a/src/providers/authentik-provider.ts b/src/providers/authentik-provider.ts index d885e02f..ca797bee 100644 --- a/src/providers/authentik-provider.ts +++ b/src/providers/authentik-provider.ts @@ -49,6 +49,9 @@ const TEST_AUTH_GROUPS: AuthentikPermissions = [ 'AMP.Viewer', 'AMP.Editor', 'AMP.Admin', + // Not implied by AMP.Admin, so the local test identity has to hold it + // explicitly or staging-gated screens are unreachable in dev. + 'AMP.Staging', 'Geothermal.Viewer', 'Geothermal.Editor', ] diff --git a/src/resources/ocotillo.tsx b/src/resources/ocotillo.tsx index 34cc9580..ded11c34 100644 --- a/src/resources/ocotillo.tsx +++ b/src/resources/ocotillo.tsx @@ -13,7 +13,7 @@ import { PictureAsPdfOutlined, Place, ScaleOutlined, - // ScienceOutlined, + ScienceOutlined, // SettingsInputAntenna, // Spa, Timeline, @@ -337,6 +337,16 @@ const ocotillo = [ icon: , }, }, + { + name: 'chemistry-report', + list: '/ocotillo/chemistry-report', + meta: { + label: 'Chemistry Reports', + parent: 'Sandbox', + nestedLevel: 1, + icon: , + }, + }, { name: 'observation', icon: , diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index 38b836f7..2b6275a2 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -8,6 +8,7 @@ import { AssetShow, UnassociatedAssetList, } from '@/pages/ocotillo/asset' +import { ChemistryReportExport } from '@/pages/ocotillo/chemistry-report' import { CollectionsPage } from '@/pages/ocotillo/collections' import { ContactList, ContactShow } from '@/pages/ocotillo/contact' import { GroundwaterLevelForm } from '@/pages/ocotillo/groundwater-level-form/stepperform' @@ -265,6 +266,16 @@ export const OcotilloRoutes = () => { } /> + + + + + } + /> + { + const actual = + await vi.importActual('@refinedev/core') + + return { + ...actual, + useGo: () => mockedGo, + useNotification: () => ({ open: mockedNotify }), + } +}) + +vi.mock('react-router', () => ({ useParams: () => ({ id: '7834' }) })) + +vi.mock('@react-pdf/renderer', () => ({ + pdf: () => ({ toBlob: mockedToBlob }), +})) + +vi.mock('@/components', () => ({ WellPDF: () => null })) + +vi.mock('@/components/pdf/chemistry', () => ({ + downloadChemistryReport: (args: unknown) => + mockedDownloadChemistryReport(args), +})) + +vi.mock('@/hooks', () => ({ + useAccessCapabilities: () => mockedUseAccessCapabilities(), + useWellChemistryReport: (args: unknown) => mockedUseWellChemistryReport(args), +})) + +// The report-type select stands in for the Radix one: this exercises the +// button group's wiring, not the primitive's open/close behavior. +vi.mock('@/components/ui/select', () => ({ + Select: ({ + value, + onValueChange, + children, + }: { + value: string + onValueChange: (value: string) => void + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + value, + children, + }: { + value: string + children: React.ReactNode + }) => , +})) + +import { WellPDFActionsButton } from '@/components/Button/WellPDFActions' +import { TooltipProvider } from '@/components/ui/tooltip' + +const well = { id: 7834, name: 'SA-0231' } as IWell + +const renderGroup = () => + render( + + + + ) + +const reportTypeSelect = () => screen.getByLabelText('Report type') +const previewButton = () => screen.getByRole('button', { name: /preview pdf/i }) +const downloadButton = () => screen.getByRole('button', { name: /^download/i }) + +const selectChemistryReport = () => + fireEvent.change(reportTypeSelect(), { + target: { value: 'chemistry-report' }, + }) + +describe('WellPDFActionsButton report type select', () => { + beforeEach(() => { + vi.clearAllMocks() + URL.createObjectURL = vi.fn(() => 'blob:pdf') + URL.revokeObjectURL = vi.fn() + mockedToBlob.mockResolvedValue(new Blob()) + mockedDownloadChemistryReport.mockResolvedValue( + 'chemistry-report-SA-0231-2024.pdf' + ) + mockedFetchYearObservations.mockResolvedValue([{ id: 'maj-1' }]) + mockedFetchWaterLevels.mockResolvedValue([ + { key: '1', measuredOn: '2024-05-15T00:00:00Z', depthToWaterFt: 9.4 }, + ]) + mockedUseAccessCapabilities.mockReturnValue({ + isLoading: false, + canManageAmp: true, + canViewConfidential: true, + canViewAmpStaging: true, + }) + mockedUseWellChemistryReport.mockReturnValue({ + reportYear: 2024, + latestSampledYear: 2024, + hasChemistry: true, + isLoading: false, + fetchYearObservations: mockedFetchYearObservations, + fetchWaterLevels: mockedFetchWaterLevels, + }) + }) + + it('offers a field sheet and a chemistry report, defaulting to the field sheet', () => { + renderGroup() + + const options = within(reportTypeSelect()).getAllByRole('option') + expect(options.map((option) => option.textContent)).toEqual([ + 'Field sheet', + 'Chemistry report', + ]) + expect(reportTypeSelect()).toHaveValue('field-sheet') + }) + + it('withholds the chemistry report from users outside the staging group', () => { + mockedUseAccessCapabilities.mockReturnValue({ + isLoading: false, + canManageAmp: true, + canViewConfidential: true, + canViewAmpStaging: false, + }) + + renderGroup() + + const options = within(reportTypeSelect()).getAllByRole('option') + expect(options.map((option) => option.textContent)).toEqual(['Field sheet']) + }) + + it('generates the field sheet while it is the selected type', async () => { + renderGroup() + + await act(async () => { + fireEvent.click(downloadButton()) + }) + + expect(mockedToBlob).toHaveBeenCalledTimes(1) + expect(mockedDownloadChemistryReport).not.toHaveBeenCalled() + }) + + it('generates the chemistry report once it is selected', async () => { + renderGroup() + selectChemistryReport() + + await act(async () => { + fireEvent.click(downloadButton()) + }) + + await waitFor(() => + expect(mockedDownloadChemistryReport).toHaveBeenCalledTimes(1) + ) + expect(mockedFetchYearObservations).toHaveBeenCalledWith(2024) + expect(mockedDownloadChemistryReport.mock.calls[0][0]).toMatchObject({ + well, + year: 2024, + observations: [{ id: 'maj-1' }], + }) + // The report's water level section is fetched for the same year. + expect(mockedFetchWaterLevels).toHaveBeenCalledWith(2024, { + elevationFt: undefined, + }) + expect( + mockedDownloadChemistryReport.mock.calls[0][0].waterLevels + ).toHaveLength(1) + expect(mockedToBlob).not.toHaveBeenCalled() + }) + + it('previews whichever report is selected', () => { + renderGroup() + + fireEvent.click(previewButton()) + expect(mockedGo).toHaveBeenCalledWith({ + to: '/ocotillo/well/pdf-preview/7834', + type: 'push', + }) + + selectChemistryReport() + fireEvent.click(previewButton()) + expect(mockedGo).toHaveBeenLastCalledWith({ + to: '/ocotillo/chemistry-report', + query: { thing_id: '7834', year: 2024 }, + type: 'push', + }) + }) + + it('still reports on a well with no chemistry, warning what is coming', async () => { + // The report is generated either way, marked as having no results, which + // is what the exporter does — a dead-end button is not the answer. + mockedUseWellChemistryReport.mockReturnValue({ + reportYear: 2026, + latestSampledYear: null, + hasChemistry: false, + isLoading: false, + fetchYearObservations: mockedFetchYearObservations, + fetchWaterLevels: mockedFetchWaterLevels, + }) + mockedFetchYearObservations.mockResolvedValue([]) + + renderGroup() + selectChemistryReport() + + expect(previewButton()).toBeEnabled() + expect(downloadButton()).toBeEnabled() + expect(previewButton()).toHaveAttribute( + 'title', + 'No water chemistry on file — the report will show no results' + ) + + await act(async () => { + fireEvent.click(downloadButton()) + }) + + expect(mockedDownloadChemistryReport.mock.calls[0][0]).toMatchObject({ + year: 2026, + observations: [], + }) + }) + + it('holds the actions back only while the reporting year is unknown', () => { + mockedUseWellChemistryReport.mockReturnValue({ + reportYear: 2026, + latestSampledYear: null, + hasChemistry: false, + isLoading: true, + fetchYearObservations: mockedFetchYearObservations, + fetchWaterLevels: mockedFetchWaterLevels, + }) + + renderGroup() + + // The field sheet does not wait on chemistry. + expect(previewButton()).toBeEnabled() + expect(downloadButton()).toBeEnabled() + + selectChemistryReport() + + expect(previewButton()).toBeDisabled() + expect(downloadButton()).toBeDisabled() + }) +}) diff --git a/src/test/hooks/useWellChemistryReport.test.tsx b/src/test/hooks/useWellChemistryReport.test.tsx new file mode 100644 index 00000000..1e121ad4 --- /dev/null +++ b/src/test/hooks/useWellChemistryReport.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChemistryResult } from '@/hooks/useChemistryReportData' + +const mockedUseList = vi.fn() +const mockedGetList = vi.fn() + +vi.mock('@refinedev/core', async () => { + const actual = + await vi.importActual('@refinedev/core') + + return { + ...actual, + useList: (args?: unknown) => mockedUseList(args), + useDataProvider: () => () => ({ getList: mockedGetList }), + } +}) + +import { useWellChemistryReport } from '@/hooks/useWellChemistryReport' + +const observation = ( + id: number, + parameterName: string, + observation_datetime: string +) => + ({ + id: `maj-${id}`, + thing_id: 7834, + parameter_name: parameterName, + observation_datetime, + value: 1, + unit: 'mg/L', + result_kind: 'major', + }) as ChemistryResult + +describe('useWellChemistryReport', () => { + beforeEach(() => { + vi.clearAllMocks() + mockedUseList.mockReturnValue({ + result: { data: [] }, + query: { isLoading: false }, + }) + }) + + it('asks for only the newest sample, newest first', () => { + renderHook(() => useWellChemistryReport({ thingId: 7834 })) + + expect(mockedUseList).toHaveBeenCalledWith( + expect.objectContaining({ + resource: 'chemistry/results', + pagination: { currentPage: 1, pageSize: 1, mode: 'server' }, + sorters: [{ field: 'observation_datetime', order: 'desc' }], + meta: { params: { thing_id: 7834 } }, + queryOptions: expect.objectContaining({ enabled: true }), + }) + ) + }) + + it('stays idle until the report is on offer', () => { + renderHook(() => useWellChemistryReport({ thingId: 7834, enabled: false })) + + expect(mockedUseList).toHaveBeenCalledWith( + expect.objectContaining({ + queryOptions: expect.objectContaining({ enabled: false }), + }) + ) + }) + + it('reports on the most recent sampled year, not the current one', () => { + mockedUseList.mockReturnValue({ + result: { data: [observation(9, 'Arsenic', '2024-05-15T00:00:00Z')] }, + query: { isLoading: false }, + }) + + const { result } = renderHook(() => + useWellChemistryReport({ thingId: 7834 }) + ) + + expect(result.current.reportYear).toBe(2024) + expect(result.current.latestSampledYear).toBe(2024) + expect(result.current.hasChemistry).toBe(true) + }) + + it('falls back to the current year when the well has never been sampled', () => { + // Reporting "no results" for this year beats having no year to offer. + const { result } = renderHook(() => + useWellChemistryReport({ thingId: 7834 }) + ) + + expect(result.current.reportYear).toBe(new Date().getFullYear()) + expect(result.current.latestSampledYear).toBeNull() + expect(result.current.hasChemistry).toBe(false) + }) + + it('pulls the year as a calendar window, sorted for the report', async () => { + mockedGetList.mockResolvedValue({ + data: [ + observation(2, 'Zinc', '2024-05-15T00:00:00Z'), + observation(1, 'Arsenic', '2024-05-15T00:00:00Z'), + ], + total: 2, + }) + + const { result } = renderHook(() => + useWellChemistryReport({ thingId: 7834 }) + ) + const observations = await result.current.fetchYearObservations(2024) + + expect(mockedGetList).toHaveBeenCalledWith( + expect.objectContaining({ + resource: 'chemistry/results', + meta: { + params: { + thing_id: 7834, + start_time: '2024-01-01T00:00:00', + end_time: '2025-01-01T00:00:00', + }, + }, + }) + ) + expect(observations.map((row) => row.parameter_name)).toEqual([ + 'Arsenic', + 'Zinc', + ]) + }) + + it('keeps paging until every result for the year is collected', async () => { + mockedGetList + .mockResolvedValueOnce({ + data: [observation(1, 'Arsenic', '2024-05-15T00:00:00Z')], + total: 2, + }) + .mockResolvedValueOnce({ + data: [observation(2, 'Zinc', '2024-05-15T00:00:00Z')], + total: 2, + }) + + const { result } = renderHook(() => + useWellChemistryReport({ thingId: 7834 }) + ) + const observations = await result.current.fetchYearObservations(2024) + + expect(mockedGetList).toHaveBeenCalledTimes(2) + expect(observations).toHaveLength(2) + }) + + it('stops rather than looping when a page comes back short of its total', async () => { + // A total that never gets reached would otherwise page forever. + mockedGetList.mockResolvedValue({ data: [], total: 99 }) + + const { result } = renderHook(() => + useWellChemistryReport({ thingId: 7834 }) + ) + const observations = await result.current.fetchYearObservations(2024) + + expect(mockedGetList).toHaveBeenCalledTimes(1) + expect(observations).toEqual([]) + }) +}) diff --git a/src/test/utils/accessControl.test.ts b/src/test/utils/accessControl.test.ts index d6cbd47f..58c2b254 100644 --- a/src/test/utils/accessControl.test.ts +++ b/src/test/utils/accessControl.test.ts @@ -38,6 +38,7 @@ const expectedRegisteredRoutableResources = [ 'geothermal.dashboard', 'geothermal.geothermal_wells', 'ocotillo.asset-unassociated', + 'ocotillo.chemistry-report', 'ocotillo.collections', 'ocotillo.contact', 'ocotillo.hydrograph-correction', @@ -57,6 +58,10 @@ const GEOTHERMAL_ROUTABLE = [ 'geothermal.geothermal_wells', ] +// AMP.Staging is an opt-in flag group, not a rung on the AMP ladder. Nothing +// else grants these resources — AMP.Admin included. +const STAGING_ONLY_ROUTABLE = ['ocotillo.chemistry-report'] + const expectedAccessByScenario: Scenario[] = [ { name: 'anonymous', @@ -91,7 +96,24 @@ const expectedAccessByScenario: Scenario[] = [ { name: 'AMP.Admin', groups: ['AMP.Admin'], - // AMP.Admin owns the water portal, not geothermal. + // AMP.Admin owns the water portal, not geothermal — and not the + // staging-flagged resources, which need the AMP.Staging group explicitly. + allowedResources: routableResources + .map((resource) => resource.name) + .filter( + (name) => + !name.startsWith('geothermal.') && + !STAGING_ONLY_ROUTABLE.includes(name) + ), + }, + { + name: 'AMP.Staging', + groups: ['AMP.Staging'], + allowedResources: [...STAGING_ONLY_ROUTABLE], + }, + { + name: 'AMP.Admin + AMP.Staging', + groups: ['AMP.Admin', 'AMP.Staging'], allowedResources: routableResources .map((resource) => resource.name) .filter((name) => !name.startsWith('geothermal.')), diff --git a/src/test/utils/chemistryReport.test.ts b/src/test/utils/chemistryReport.test.ts new file mode 100644 index 00000000..25613fed --- /dev/null +++ b/src/test/utils/chemistryReport.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from 'vitest' +import { compareToStandard } from '@/constants/drinkingWaterStandards' +import type { ChemistryResult } from '@/hooks/useChemistryReportData' +import { + buildChemistryReportFilename, + chemistryReportYearOf, + chemistryReportYearParams, + formatResultValue, + latestResultPerParameter, + pivotFieldParameters, + resultStatus, + sortChemistryResults, + summarizeChemistry, + toWaterLevelReadings, + waterLevelChangeFt, +} from '@/utils/chemistryReport' + +const observation = ( + overrides: Partial & { + parameterName: string + parameterType?: string | null + } +): ChemistryResult => { + const { parameterName, parameterType = 'Metal', ...rest } = overrides + + return { + id: 'maj-1', + thing_id: 2161, + station_name: 'EB-339', + sample_id: 1, + parameter_name: parameterName, + value: 0, + unit: 'mg/L', + observation_datetime: '2026-05-15T00:00:00Z', + // The legacy source table stands in for the old parameter_type: a field + // reading came off the wellhead, anything else came from a lab. + result_kind: parameterType === 'Field Parameter' ? 'field' : 'minor', + ...rest, + } as ChemistryResult +} + +describe('compareToStandard', () => { + it('flags a result above its MCL', () => { + expect(compareToStandard('Arsenic', 0.012, 'mg/L')).toMatchObject({ + exceeds: true, + standard: { kind: 'MCL', limit: 0.01 }, + }) + }) + + it('treats a result exactly at the limit as within the limit', () => { + expect(compareToStandard('Arsenic', 0.01, 'mg/L').exceeds).toBe(false) + }) + + it('refuses to compare across units rather than misapplying the limit', () => { + // 12 µg/L is 0.012 mg/L — above the limit — but the numbers are not + // comparable as given, so the row must not be flagged from the raw value. + expect(compareToStandard('Arsenic', 12, 'ug/L').exceeds).toBe(false) + }) + + it('reports no standard for an unregulated parameter', () => { + expect(compareToStandard('Calcium', 90, 'mg/L')).toEqual({ + standard: undefined, + exceeds: false, + }) + }) +}) + +describe('summarizeChemistry', () => { + const rows = [ + observation({ id: 'maj-1', parameterName: 'Arsenic', value: 0.012 }), + observation({ id: 'maj-2', parameterName: 'Iron', value: 0.9 }), + observation({ id: 'maj-3', parameterName: 'Calcium', value: 90 }), + observation({ + id: 'fld-4', + parameterName: 'pH', + parameterType: 'Field Parameter', + value: 7.8, + unit: 'dimensionless', + observation_datetime: '2026-02-04T00:00:00Z', + }), + ] + + const summary = summarizeChemistry(rows) + + it('splits field parameters from laboratory results', () => { + expect(summary.fieldParameters.map((row) => row.parameterName)).toEqual([ + 'pH', + ]) + expect(summary.labResults).toHaveLength(3) + }) + + it('separates health limits from taste and odor guidelines', () => { + expect(summary.mclExceedances.map((row) => row.parameterName)).toEqual([ + 'Arsenic', + ]) + expect(summary.smclExceedances.map((row) => row.parameterName)).toEqual([ + 'Iron', + ]) + }) + + it('counts distinct sample dates and compared parameters', () => { + expect(summary.sampleDates).toEqual(['2026-02-04', '2026-05-15']) + expect(summary.parameterCount).toBe(4) + expect(summary.comparedCount).toBe(2) + }) + + it('handles a well with no chemistry on file', () => { + expect(summarizeChemistry([])).toMatchObject({ + sampleDates: [], + parameterCount: 0, + mclExceedances: [], + }) + }) +}) + +describe('formatResultValue', () => { + it('preserves lab precision instead of rounding to the limit', () => { + expect(formatResultValue(0.012)).toBe('0.012') + }) + + it('labels a null result rather than printing zero', () => { + expect(formatResultValue(null)).toBe('Not detected') + }) +}) + +describe('buildChemistryReportFilename', () => { + it('slugifies the well name', () => { + expect( + buildChemistryReportFilename({ id: 1187, name: 'WL-1187' }, 2026) + ).toBe('chemistry-report-WL-1187-2026.pdf') + }) + + it('falls back to the id when the well has no name', () => { + expect(buildChemistryReportFilename(undefined, 2026)).toBe( + 'chemistry-report-well-unknown-2026.pdf' + ) + }) +}) + +describe('chemistryReportYearParams', () => { + it('covers the calendar year without spilling into the next one', () => { + expect(chemistryReportYearParams(2026)).toEqual({ + start_time: '2026-01-01T00:00:00', + end_time: '2027-01-01T00:00:00', + }) + }) +}) + +describe('chemistryReportYearOf', () => { + it('reads the year in UTC so a Jan 01 sample is not filed a year early', () => { + // Local time west of Greenwich makes this Dec 31, 2025; the API window it + // has to match is a UTC one, so 2026 is the year that returns the sample. + expect(chemistryReportYearOf('2026-01-01T00:00:00Z')).toBe(2026) + }) + + it('returns null for a missing or unparseable date', () => { + expect(chemistryReportYearOf(null)).toBeNull() + expect(chemistryReportYearOf('not a date')).toBeNull() + }) +}) + +describe('sortChemistryResults', () => { + it('orders oldest sample first, then parameters alphabetically', () => { + const sorted = sortChemistryResults([ + observation({ + id: 'maj-1', + parameterName: 'Iron', + observation_datetime: '2026-05-15T00:00:00Z', + }), + observation({ + id: 'maj-2', + parameterName: 'Zinc', + observation_datetime: '2026-02-04T00:00:00Z', + }), + observation({ + id: 'maj-3', + parameterName: 'Arsenic', + observation_datetime: '2026-02-04T00:00:00Z', + }), + ]) + + expect(sorted.map((row) => row.parameter_name)).toEqual([ + 'Arsenic', + 'Zinc', + 'Iron', + ]) + }) + + it('does not mutate the array it is given', () => { + const rows = [ + observation({ id: 'maj-1', parameterName: 'Zinc' }), + observation({ + id: 'maj-2', + parameterName: 'Arsenic', + observation_datetime: '2026-02-04T00:00:00Z', + }), + ] + + sortChemistryResults(rows) + + expect(rows.map((row) => row.id)).toEqual(['maj-1', 'maj-2']) + }) +}) + +describe('resultStatus', () => { + const row = ( + overrides: Partial['rows'][number]> + ): ReturnType['rows'][number] => ({ + key: 'maj-1', + parameterName: 'Arsenic', + resultKind: 'minor' as const, + value: 0.005, + unit: 'mg/L', + sampledOn: '2026-05-15T00:00:00Z', + exceeds: false, + ...overrides, + }) + + it('separates a health limit from a taste guideline', () => { + expect( + resultStatus( + row({ + exceeds: true, + standard: { kind: 'MCL', limit: 0.01, unit: 'mg/L' }, + }) + ) + ).toEqual({ kind: 'above-mcl', label: 'Above limit' }) + + expect( + resultStatus( + row({ + parameterName: 'Iron', + exceeds: true, + standard: { kind: 'SMCL', limit: 0.3, unit: 'mg/L' }, + }) + ) + ).toEqual({ kind: 'above-smcl', label: 'Above SMCL' }) + }) + + it('reports a missing value as not detected rather than as passing', () => { + expect(resultStatus(row({ value: null })).kind).toBe('not-detected') + }) + + it('describes hardness instead of passing or failing it', () => { + // Hardness has no standard, so a pass/fail verdict would be invented. + expect( + resultStatus(row({ parameterName: 'Hardness (CaCO3)', value: 284 })) + ).toEqual({ kind: 'classification', label: 'Very hard' }) + expect( + resultStatus(row({ parameterName: 'Hardness (CaCO3)', value: 45 })).label + ).toBe('Soft') + }) + + it('says nothing about a parameter with no standard', () => { + expect(resultStatus(row({ parameterName: 'Strontium' })).kind).toBe('none') + }) +}) + +describe('pivotFieldParameters', () => { + it('gives each parameter one row and each sample date a column', () => { + const { dates, rows } = pivotFieldParameters([ + { + key: 'fld-1', + parameterName: 'pH', + resultKind: 'field', + value: 7.61, + unit: 'S.U.', + sampledOn: '2026-02-04T00:00:00Z', + exceeds: false, + }, + { + key: 'fld-2', + parameterName: 'pH', + resultKind: 'field', + value: 7.55, + unit: 'S.U.', + sampledOn: '2026-05-15T00:00:00Z', + exceeds: false, + }, + ]) + + expect(dates).toEqual(['2026-02-04', '2026-05-15']) + expect(rows).toHaveLength(1) + expect(rows[0].valuesByDate).toEqual({ + '2026-02-04': '7.61', + '2026-05-15': '7.55', + }) + }) +}) + +describe('latestResultPerParameter', () => { + const result = ( + key: string, + parameterName: string, + sampledOn: string, + extra: Record = {} + ) => + ({ + key, + parameterName, + resultKind: 'minor', + value: 1, + unit: 'mg/L', + sampledOn, + exceeds: false, + ...extra, + }) as ReturnType['rows'][number] + + it('keeps each parameter once, at its newest value', () => { + const { rows, dateRange } = latestResultPerParameter([ + result('a', 'Arsenic', '2026-02-04T00:00:00Z'), + result('b', 'Arsenic', '2026-05-15T00:00:00Z'), + result('c', 'Iron', '2026-05-15T00:00:00Z'), + ]) + + expect(rows.map((row) => row.key)).toEqual(['b', 'c']) + expect(dateRange).toEqual(['2026-05-15', '2026-05-15']) + }) + + it('keeps a parameter sampled on its own visit rather than dropping it', () => { + // Majors and trace metals routinely come from different trips. Keying the + // table to one date would leave a flagged parameter with no row. + const { rows, dateRange } = latestResultPerParameter([ + result('tds', 'Total Dissolved Solids', '2019-04-09T00:00:00Z', { + exceeds: true, + standard: { kind: 'SMCL', limit: 500, unit: 'mg/L' }, + }), + result('arsenic', 'Arsenic', '2019-05-24T00:00:00Z'), + ]) + + expect(rows.map((row) => row.key)).toEqual(['tds', 'arsenic']) + expect(dateRange).toEqual(['2019-04-09', '2019-05-24']) + }) + + it('puts exceedances first, health limits before taste limits', () => { + const { rows } = latestResultPerParameter([ + result('iron', 'Iron', '2026-05-15T00:00:00Z', { + exceeds: true, + standard: { kind: 'SMCL', limit: 0.3, unit: 'mg/L' }, + }), + result('calcium', 'Calcium', '2026-05-15T00:00:00Z'), + result('arsenic', 'Arsenic', '2026-05-15T00:00:00Z', { + exceeds: true, + standard: { kind: 'MCL', limit: 0.01, unit: 'mg/L' }, + }), + ]) + + expect(rows.map((row) => row.key)).toEqual(['arsenic', 'iron', 'calcium']) + }) +}) + +describe('toWaterLevelReadings', () => { + const observations = [ + { + id: 1, + observation_datetime: '2019-04-09T20:02:00Z', + depth_to_water_bgs: 9.35, + sensor_id: null, + }, + { + id: 2, + observation_datetime: '2018-10-04T20:39:00Z', + depth_to_water_bgs: 10.5, + sensor_id: 7, + }, + ] + + it('works the water table elevation out from the land surface', () => { + const readings = toWaterLevelReadings(observations, { elevationFt: 5856.8 }) + + expect(readings[0].measuredOn).toBe('2019-04-09T20:02:00Z') + // 5856.8 - 9.35, rounded to the tenth of a foot the report prints. + expect(readings[0].waterElevationFt).toBe(5847.4) + expect(readings[0].method).toBe('Manual') + expect(readings[1].method).toBe('Transducer') + }) + + it('leaves elevation empty rather than printing the depth twice', () => { + const readings = toWaterLevelReadings(observations) + expect(readings[0].waterElevationFt).toBeNull() + expect(readings[0].depthToWaterFt).toBe(9.35) + }) +}) + +describe('waterLevelChangeFt', () => { + it('reads a deeper newest reading as a fall in water level', () => { + // Depth is measured downward, so deeper is lower. + const readings = toWaterLevelReadings([ + { + id: 1, + observation_datetime: '2019-04-09T00:00:00Z', + depth_to_water_bgs: 12.3, + }, + { + id: 2, + observation_datetime: '2018-04-09T00:00:00Z', + depth_to_water_bgs: 10.5, + }, + ]) + + expect(waterLevelChangeFt(readings)).toEqual({ + changeFt: -1.8, + comparedTo: '2018-04-09T00:00:00Z', + }) + }) + + it('reports nothing when there is only one reading to go on', () => { + const readings = toWaterLevelReadings([ + { + id: 1, + observation_datetime: '2019-04-09T00:00:00Z', + depth_to_water_bgs: 12.3, + }, + ]) + expect(waterLevelChangeFt(readings)).toBeNull() + }) +}) diff --git a/src/utils/accessControl.ts b/src/utils/accessControl.ts index 95d61d6b..2d38218a 100644 --- a/src/utils/accessControl.ts +++ b/src/utils/accessControl.ts @@ -6,21 +6,40 @@ import type { } from '@/interfaces/ocotillo/IContact' export type AmpRole = 'AMP.Viewer' | 'AMP.Editor' | 'AMP.Admin' +/** + * Opt-in flag group for features that are still being reviewed. It is not a + * rung on the AMP.Viewer → AMP.Editor → AMP.Admin ladder: holding it grants + * nothing else, and holding AMP.Admin does not imply it. A user has to be put + * in the group deliberately. + * + * Spelled exactly as Authentik spells it, because groups arrive as strings in + * the token's `groups` claim and are matched literally -- `AMP.staging` looks + * right and never matches anything. The API spells it the same way, in + * core/dependencies.py. + */ +export type AmpStagingRole = 'AMP.Staging' export type GeothermalRole = | 'Geothermal.Viewer' | 'Geothermal.Editor' | 'Geothermal.Admin' -export type PortalRole = AmpRole | GeothermalRole +export type PortalRole = AmpRole | AmpStagingRole | GeothermalRole const roleOrder: PortalRole[] = [ 'AMP.Viewer', 'AMP.Editor', 'AMP.Admin', + 'AMP.Staging', 'Geothermal.Viewer', 'Geothermal.Editor', 'Geothermal.Admin', ] +/** + * Roles that carry no hierarchy — they pass through normalization as-is + * instead of being expanded from a domain ladder. + */ +const standaloneRoles: PortalRole[] = ['AMP.Staging'] + export const wipResources = new Set([ 'water.dashboard', 'water.reportbuilder', @@ -51,6 +70,7 @@ const geothermalEditorRoles: PortalRole[] = [ 'Geothermal.Admin', ] const geothermalAdminRoles: PortalRole[] = ['Geothermal.Admin'] +const stagingRoles: PortalRole[] = ['AMP.Staging'] const adminOnlyRoles = new Set(['AMP.Admin', 'Geothermal.Admin']) const resourcePolicies: Record = { @@ -116,6 +136,7 @@ const resourcePolicies: Record = { manage: adminRoles, }, Sandbox: { list: adminRoles, show: adminRoles }, + 'ocotillo.chemistry-report': { list: stagingRoles, show: stagingRoles }, geothermal: { list: geothermalViewerRoles, show: geothermalViewerRoles }, 'water.locations': { list: ['AMP.Admin', 'Geothermal.Admin'], @@ -169,6 +190,12 @@ export const normalizeAccessControlGroups = ( ['Geothermal.Viewer', 'Geothermal.Editor', 'Geothermal.Admin'], ] + for (const role of standaloneRoles) { + if (normalized.has(role)) { + expandedRoles.add(role) + } + } + for (const hierarchy of domainHierarchies) { if (normalized.has(hierarchy[2])) { hierarchy.forEach((role) => expandedRoles.add(role)) @@ -190,7 +217,11 @@ export const normalizeAccessControlGroups = ( export const getPrimaryRole = ( groups: string[] | null | undefined ): PortalRole | null => { - const normalized = normalizeAccessControlGroups(groups) + // Standalone flag groups are not a rank, so they never become the label a + // user is shown as holding. + const normalized = normalizeAccessControlGroups(groups).filter( + (role) => !standaloneRoles.includes(role) + ) return normalized.length > 0 ? normalized[normalized.length - 1] : null } @@ -205,6 +236,7 @@ export const getAccessCapabilities = (groups: string[] | null | undefined) => { const canManageAmp = roles.includes('AMP.Admin') const canViewConfidential = canEditAmp const canViewUnfinished = canManageAmp + const canViewAmpStaging = roles.includes('AMP.Staging') const canViewGeothermal = roles.includes('Geothermal.Viewer') || roles.includes('Geothermal.Editor') || @@ -221,6 +253,7 @@ export const getAccessCapabilities = (groups: string[] | null | undefined) => { canManageAmp, canViewConfidential, canViewUnfinished, + canViewAmpStaging, canViewGeothermal, canEditGeothermal, canManageGeothermal, @@ -266,6 +299,7 @@ export const canAccessResource = ({ if ( resource === 'ocotillo.hydrograph-correction' || resource === 'ocotillo.thing-well-pdf-preview' || + resource === 'ocotillo.chemistry-report' || resource === 'Sandbox' ) { const policy = resourcePolicies[resource] diff --git a/src/utils/chemistryReport.ts b/src/utils/chemistryReport.ts new file mode 100644 index 00000000..6857c0c4 --- /dev/null +++ b/src/utils/chemistryReport.ts @@ -0,0 +1,409 @@ +import { + compareToStandard, + type DrinkingWaterStandard, +} from '@/constants/drinkingWaterStandards' +import type { + ChemistryResult, + ChemistryResultKind, +} from '@/hooks/useChemistryReportData' +import type { IWell } from '@/interfaces/ocotillo' + +export type ChemistryResultRow = { + key: string + parameterName: string + /** Which legacy table the result came from; 'field' was read at the well. */ + resultKind: ChemistryResultKind + value: number | null + unit: string | null + sampledOn: string + standard?: DrinkingWaterStandard + exceeds: boolean +} + +export type ChemistryReportSummary = { + rows: ChemistryResultRow[] + fieldParameters: ChemistryResultRow[] + labResults: ChemistryResultRow[] + sampleDates: string[] + parameterCount: number + comparedCount: number + mclExceedances: ChemistryResultRow[] + smclExceedances: ChemistryResultRow[] +} + +/** + * Page size used when pulling one well's chemistry for one reporting year. A + * year of results for a single well is small; the ceiling only exists so a + * well with an unusually long parameter list is not silently truncated. + */ +export const CHEMISTRY_REPORT_PAGE_SIZE = 500 + +/** + * The API's start_time/end_time window is inclusive of the start and exclusive + * of the end, so a calendar year runs from Jan 1 to Jan 1 of the next year. + */ +export const chemistryReportYearParams = (year: number) => ({ + start_time: `${year}-01-01T00:00:00`, + end_time: `${year + 1}-01-01T00:00:00`, +}) + +/** + * The calendar year a sample belongs to, read in UTC to match the window + * `chemistryReportYearParams` builds. Reading it locally would file a sample + * collected Jan 01 under the previous year anywhere west of Greenwich, and the + * report for that year would then come back empty. + */ +export const chemistryReportYearOf = (value?: string | null): number | null => { + if (!value) return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + return date.getUTCFullYear() +} + +/** Oldest sample first, parameters alphabetical within a sample date. */ +export const sortChemistryResults = ( + observations: readonly ChemistryResult[] +): ChemistryResult[] => + [...observations].sort((a, b) => { + const byDate = + new Date(a.observation_datetime).getTime() - + new Date(b.observation_datetime).getTime() + if (byDate !== 0) return byDate + return (a.parameter_name ?? '').localeCompare(b.parameter_name ?? '') + }) + +/** + * Sample and completion dates are calendar dates, not instants. The API sends + * them as UTC (or as a bare `YYYY-MM-DD`, which parses as UTC midnight), so + * they are formatted in UTC — formatting in the viewer's local zone would + * print a sample collected Feb 04 as Feb 03 anywhere west of Greenwich. + */ +export const formatReportDate = (value?: string | null): string => { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '—' + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + timeZone: 'UTC', + }) +} + +/** + * Results are reported at the precision the lab gave us rather than a fixed + * number of decimals — an arsenic result of 0.012 mg/L must not be rounded to + * 0.01 mg/L, which is the limit it is being compared against. + */ +export const formatResultValue = (value: number | null): string => + value == null ? 'Not detected' : String(value) + +export const summarizeChemistry = ( + observations: readonly ChemistryResult[] +): ChemistryReportSummary => { + const rows: ChemistryResultRow[] = observations.map((observation) => { + const parameterName = observation.parameter_name || 'Unknown' + const unit = observation.unit ?? null + const { standard, exceeds } = compareToStandard( + parameterName, + observation.value, + unit + ) + + return { + key: observation.id, + parameterName, + resultKind: observation.result_kind, + value: observation.value, + unit, + sampledOn: observation.observation_datetime, + standard, + exceeds, + } + }) + + const sampleDates = Array.from( + new Set(rows.map((row) => row.sampledOn.slice(0, 10))) + ).sort() + + return { + rows, + fieldParameters: rows.filter((row) => row.resultKind === 'field'), + labResults: rows.filter((row) => row.resultKind !== 'field'), + sampleDates, + parameterCount: new Set(rows.map((row) => row.parameterName)).size, + comparedCount: new Set( + rows.filter((row) => row.standard).map((row) => row.parameterName) + ).size, + mclExceedances: rows.filter( + (row) => row.exceeds && row.standard?.kind === 'MCL' + ), + smclExceedances: rows.filter( + (row) => row.exceeds && row.standard?.kind === 'SMCL' + ), + } +} + +/** + * `WL-1187 Vigil Ranch Well` → `chemistry-report-WL-1187-2026.pdf` + */ +export const buildChemistryReportFilename = ( + well: Pick | undefined, + year: number +): string => { + const slug = (well?.name ?? `well-${well?.id ?? 'unknown'}`) + .trim() + .replace(/\s+/g, '-') + .replace(/[^A-Za-z0-9._-]/g, '') + return `chemistry-report-${slug}-${year}.pdf` +} + +/** + * How a result reads against its standard, as the report prints it. Kept as a + * tagged union rather than a string so the PDF cannot invent a status the + * comparison logic did not actually produce. + */ +export type ChemistryStatus = + | { kind: 'above-mcl'; label: 'Above limit' } + | { kind: 'above-smcl'; label: 'Above SMCL' } + | { kind: 'below'; label: 'Below limit' } + | { kind: 'not-detected'; label: 'Not detected' } + | { kind: 'classification'; label: string } + | { kind: 'none'; label: '—' } + +/** + * Hardness has no drinking water standard -- it is a nuisance and appliance + * concern -- so it is reported as the descriptive class the USGS scale gives + * it instead of as a pass or fail. + */ +const hardnessClass = (value: number): string => { + if (value < 60) return 'Soft' + if (value <= 120) return 'Moderately hard' + if (value <= 180) return 'Hard' + return 'Very hard' +} + +const HARDNESS_PARAMETERS = new Set(['Hardness (CaCO3)', 'Hardness']) + +export const resultStatus = (row: ChemistryResultRow): ChemistryStatus => { + if (row.value == null) return { kind: 'not-detected', label: 'Not detected' } + + if (HARDNESS_PARAMETERS.has(row.parameterName)) { + return { kind: 'classification', label: hardnessClass(row.value) } + } + + if (!row.standard) return { kind: 'none', label: '—' } + + if (row.exceeds) { + return row.standard.kind === 'MCL' + ? { kind: 'above-mcl', label: 'Above limit' } + : { kind: 'above-smcl', label: 'Above SMCL' } + } + + return { kind: 'below', label: 'Below limit' } +} + +/** `0.010 mg/L` for a row's limit, or a dash when it has no standard. */ +export const formatStandardLimit = (row: ChemistryResultRow): string => + row.standard ? String(row.standard.limit) : 'no standard' + +/** + * Field parameters as one row per parameter with a column per sample date, + * which is how they are read -- the same handful of measurements repeated at + * each visit, compared across visits. + */ +export type FieldParameterRow = { + parameterName: string + unit: string | null + valuesByDate: Record +} + +export const pivotFieldParameters = ( + rows: readonly ChemistryResultRow[] +): { dates: string[]; rows: FieldParameterRow[] } => { + const dates = Array.from( + new Set(rows.map((row) => row.sampledOn.slice(0, 10))) + ).sort() + + const byParameter = new Map() + for (const row of rows) { + const existing = byParameter.get(row.parameterName) ?? { + parameterName: row.parameterName, + unit: row.unit, + valuesByDate: {}, + } + existing.valuesByDate[row.sampledOn.slice(0, 10)] = formatResultValue( + row.value + ) + existing.unit = existing.unit ?? row.unit + byParameter.set(row.parameterName, existing) + } + + return { + dates, + rows: [...byParameter.values()].sort((a, b) => + a.parameterName.localeCompare(b.parameterName) + ), + } +} + +/** + * The results the chemistry table prints: each parameter once, at its most + * recent value in the period. + * + * Not "the latest sample" -- a well is rarely sampled for everything on the + * same day. Over a year of visits the majors come from one trip and the trace + * metals from another, so keying the table to a single date drops most of the + * record and can leave a parameter called out as over its limit with no row to + * show for it. + */ +export const latestResultPerParameter = ( + rows: readonly ChemistryResultRow[] +): { rows: ChemistryResultRow[]; dateRange: [string, string] | null } => { + if (rows.length === 0) return { rows: [], dateRange: null } + + const newestByParameter = new Map() + for (const row of rows) { + const existing = newestByParameter.get(row.parameterName) + if (!existing || row.sampledOn > existing.sampledOn) { + newestByParameter.set(row.parameterName, row) + } + } + + const kept = [...newestByParameter.values()].sort((a, b) => { + // Exceedances first: the reason the report exists goes at the top. + const rank = (row: ChemistryResultRow) => + row.exceeds ? (row.standard?.kind === 'MCL' ? 0 : 1) : 2 + const byRank = rank(a) - rank(b) + if (byRank !== 0) return byRank + return a.parameterName.localeCompare(b.parameterName) + }) + + const days = kept.map((row) => row.sampledOn.slice(0, 10)).sort() + + return { rows: kept, dateRange: [days[0], days[days.length - 1]] } +} + +/** One water level reading as the report's table prints it. */ +export type WaterLevelReading = { + key: string + measuredOn: string + depthToWaterFt: number | null + waterElevationFt: number | null + method: string + /** True for a reading carried in from before the reporting year. */ + isPrior: boolean +} + +export type WaterLevelObservation = { + id: number | string + observation_datetime: string + value?: number | null + depth_to_water_bgs?: number | null + sensor_id?: number | null +} + +/** + * Water level readings, deepest-first by date, with the water table elevation + * worked out where the land surface elevation is known. + * + * Depth to water is measured downward from the ground and elevation is + * measured upward from sea level, so the water table sits at the difference. + * Without a land surface elevation the column is left empty rather than + * printing the depth twice under two different headings. + */ +export const toWaterLevelReadings = ( + observations: readonly WaterLevelObservation[], + { elevationFt }: { elevationFt?: number | null } = {} +): WaterLevelReading[] => + [...observations] + .sort( + (a, b) => + new Date(b.observation_datetime).getTime() - + new Date(a.observation_datetime).getTime() + ) + .map((observation) => { + const depth = observation.depth_to_water_bgs ?? observation.value ?? null + + return { + key: String(observation.id), + measuredOn: observation.observation_datetime, + depthToWaterFt: depth, + waterElevationFt: + elevationFt != null && depth != null + ? Number((elevationFt - depth).toFixed(1)) + : null, + // The legacy records carry no method field. A reading tied to a sensor + // came off a transducer; anything else was read by hand. + method: observation.sensor_id == null ? 'Manual' : 'Transducer', + isPrior: false, + } + }) + +/** + * Change in water level between the newest reading and the one before it, as + * a signed depth change in feet. Negative means the water table fell. + */ +export const waterLevelChangeFt = ( + readings: readonly WaterLevelReading[] +): { changeFt: number; comparedTo: string } | null => { + const measured = readings.filter((reading) => reading.depthToWaterFt != null) + if (measured.length < 2) return null + + const [newest, previous] = measured + // Depth grows downward, so a deeper reading is a fall in water level. + const changeFt = Number( + ( + (previous.depthToWaterFt as number) - (newest.depthToWaterFt as number) + ).toFixed(1) + ) + + return { changeFt, comparedTo: previous.measuredOn } +} + +/** + * Readable labels for legacy analyte symbols the lexicon has no term for. + * + * The API deliberately leaves these as symbols -- inventing a parameter name + * would put vocabulary in `parameter_name` that nothing else in the system + * knows -- but `CF` on a page handed to a well owner is just noise. None of + * these carry a drinking water standard, so relabelling them for print cannot + * cause a limit to be applied to the wrong quantity. + * + * `CF` is read as field specific conductance: it arrives in µS/cm alongside a + * separate laboratory conductivity measurement. + */ +const DISPLAY_LABELS: Record = { + CF: 'Specific conductance (field)', + // Plain '3', not the subscript: Helvetica has no U+2083 and react-pdf + // substitutes an italic f for it. + 'Hardness (CaCO3)': 'Hardness (as CaCO3)', + DO: 'Dissolved oxygen', + ORP: 'Oxidation-reduction potential', + TDS: 'Total dissolved solids', +} + +export const displayParameterName = (parameterName: string): string => + DISPLAY_LABELS[parameterName] ?? parameterName + +/** + * The results the chemistry table prints, and how many it left out. + * + * A well can carry a hundred analytes in a year, most of them unregulated + * trace metals sitting at their detection limit. Printing all of them buries + * the handful a reader can act on, so the table keeps the ones measured + * against a standard -- plus hardness, which has no standard but drives + * appliance and softener decisions -- and says how many others are on file. + */ +export const reportableResults = ( + rows: readonly ChemistryResultRow[] +): { rows: ChemistryResultRow[]; omittedCount: number } => { + const reportable = rows.filter( + (row) => row.standard || HARDNESS_PARAMETERS.has(row.parameterName) + ) + + return { + rows: reportable, + omittedCount: rows.length - reportable.length, + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index bc91b2c0..6233421c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './accessControl' export * from './ApiUriBuilder' export * from './Auth' export * from './BuildPdfFilename' +export * from './chemistryReport' export * from './CreatePdfStyles' export * from './Date' export * from './FallbackWithDefault'