diff --git a/.env.production.example b/.env.production.example index 21089cc3..361965e0 100644 --- a/.env.production.example +++ b/.env.production.example @@ -10,6 +10,9 @@ VITE_POSTHOG_HOST=https://us.i.posthog.com VITE_APP_ENV=production VITE_APP_TIMEZONE=America/Denver +# Optional: base URL for well detail links in map CSV export (defaults to https://ocotillo.newmexicowaterdata.org) +# VITE_OCOTILLO_PUBLIC_APP_URL=https://ocotillo.newmexicowaterdata.org + VITE_AUTHENTIK_CLIENT_ID= VITE_AUTHENTIK_URL= VITE_AUTHENTIK_REDIRECT_URI= diff --git a/src/components/WellShow/Contacts.tsx b/src/components/WellShow/Contacts.tsx index 3c6f0aa0..9b66d172 100644 --- a/src/components/WellShow/Contacts.tsx +++ b/src/components/WellShow/Contacts.tsx @@ -47,6 +47,15 @@ const ContactBlock = ({ contact }: { contact: IContact }) => { {roleType} )} + {contact.name && ( + + Contact name + + )} {contact.name && contact.id && ( { {contact.organization || 'No organization listed'} + {emails.length > 0 && ( + + Email + + )} {emails.map((email, idx) => ( { {email} ))} + {phones.length > 0 && ( + + Phone + + )} {phones.map((phone, idx) => ( { captureEvent('feature_used', { feature: 'map' }) }, []) + const dataProvider = useDataProvider() + const [exportVisibleBusy, setExportVisibleBusy] = useState(false) + const location = useLocation() const mapContainerRef = useRef(null) const mapRef = useRef(null) @@ -514,7 +525,11 @@ export const MapView: React.FC = () => { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `${sanitizeLayerExportFilename(label)}.${suffix}` + const base = sanitizeLayerExportFilename(label) + link.download = + suffix === 'csv' + ? `${base}-${localDateStampForExport()}.csv` + : `${base}.${suffix}` document.body.appendChild(link) link.click() document.body.removeChild(link) @@ -544,7 +559,9 @@ export const MapView: React.FC = () => { } downloadLayerBlob( - buildLayerCsv(features), + buildLayerCsv(features, { + preferredPropertyColumnOrder: buildMapExportPreferredColumnOrder(), + }), 'text/csv;charset=utf-8;', 'csv', label, @@ -552,18 +569,31 @@ export const MapView: React.FC = () => { ) } - const onExportVisiblePoints = () => { - visiblePointFeaturesByLayer.forEach(({ label, features }, index) => { - exportLayerCollection( - { - type: 'FeatureCollection', + const onExportVisiblePoints = async () => { + setExportVisibleBusy(true) + try { + const ocotillo = dataProvider('ocotillo') + const customRequest = (args: CustomParams) => ocotillo.custom(args) + + for (let index = 0; index < visiblePointFeaturesByLayer.length; index++) { + const { label, features } = visiblePointFeaturesByLayer[index] + const enriched = await enrichMapFeaturesWithWellDetails( features, - }, - label, - exportFormat, - index - ) - }) + customRequest + ) + exportLayerCollection( + { + type: 'FeatureCollection', + features: enriched, + }, + label, + exportFormat, + index + ) + } + } finally { + setExportVisibleBusy(false) + } } const onExportLayer = () => { @@ -1536,12 +1566,13 @@ export const MapView: React.FC = () => { { + void onExportVisiblePoints() + }} buttonLabel="Export Visible" selectorWidth={142} - tooltip={`Click to download the visible features for each active dataset as separate ${ - exportFormat === 'csv' ? 'CSV' : 'GeoJSON' - } files.`} + disabled={exportVisibleBusy} + tooltip="Downloads one file per visible dataset. CSV and GeoJSON merge OGC properties with Ocotillo well details (full well JSON, contacts, location, and a public well detail link). Fetches the details API for each visible well id (batched)." /> {`${paginatedVisibleFeatureGroups.start + 1}-${paginatedVisibleFeatureGroups.end} of ${paginatedVisibleFeatureGroups.total}`} diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index 7469ce87..b29c4f7b 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -8,6 +8,7 @@ import { PictureAsPdf } from '@mui/icons-material' import { ListPage } from '@/components/ListPage' import { ISpring, IWell } from '@/interfaces/ocotillo' import { displayWellSiteName, formatAppDate, formatAppDateTime } from '@/utils' +import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' export const SpringList: React.FC = () => { const { dataGridProps } = useDataGrid({ @@ -115,7 +116,7 @@ export const WellList: React.FC = () => { () => [ { field: 'name', - headerName: 'Name', + headerName: WellListColumnLabels.name, description: 'Official well identifier used in bureau records (for example county prefix and local ID).', type: 'string', @@ -124,7 +125,7 @@ export const WellList: React.FC = () => { }, { field: 'site_name', - headerName: 'Site name', + headerName: WellListColumnLabels.siteName, description: 'Name of the monitoring site or facility associated with this well when one is recorded (NMBGMR alternate ID when present).', type: 'string', @@ -134,7 +135,7 @@ export const WellList: React.FC = () => { }, { field: 'monitoring_status', - headerName: 'Monitoring', + headerName: WellListColumnLabels.monitoring, description: 'Whether the well is actively monitored or how monitoring is categorized in the current record.', type: 'string', @@ -142,7 +143,7 @@ export const WellList: React.FC = () => { }, { field: 'created_at', - headerName: 'Created At', + headerName: WellListColumnLabels.createdAt, description: 'Calendar date when this well record was first added to Ocotillo.', width: 130, @@ -150,14 +151,14 @@ export const WellList: React.FC = () => { }, { field: 'well_status', - headerName: 'Well Status', + headerName: WellListColumnLabels.wellStatus, description: 'Operational or administrative status of the well.', type: 'string', width: 150, }, { field: 'thing_type', - headerName: 'Type', + headerName: WellListColumnLabels.type, description: 'Infrastructure type from the controlled vocabulary (for example water well or geothermal well).', type: 'string', @@ -165,7 +166,7 @@ export const WellList: React.FC = () => { }, { field: 'aquifers', - headerName: 'Aquifers', + headerName: WellListColumnLabels.aquifers, description: 'Aquifer systems linked to this well, summarized from association data. Sort uses the first aquifer name alphabetically among linked systems.', minWidth: 180, @@ -180,7 +181,7 @@ export const WellList: React.FC = () => { }, { field: 'release_status', - headerName: 'Release Status', + headerName: WellListColumnLabels.releaseStatus, description: 'Whether the record is released for public viewing under data release rules.', type: 'string', @@ -188,7 +189,7 @@ export const WellList: React.FC = () => { }, { field: 'well_depth', - headerName: 'Well Depth (ft)', + headerName: WellListColumnLabels.wellDepthFt, description: 'Completed well depth from ground surface to bottom of the well in feet.', type: 'number', @@ -198,7 +199,7 @@ export const WellList: React.FC = () => { }, { field: 'hole_depth', - headerName: 'Hole Depth (ft)', + headerName: WellListColumnLabels.holeDepthFt, description: 'Total drilled hole depth from ground surface to bottom of the borehole in feet.', type: 'number', @@ -208,7 +209,7 @@ export const WellList: React.FC = () => { }, { field: 'first_visit_date', - headerName: 'First Visit', + headerName: WellListColumnLabels.firstVisit, description: 'Date of the bureau first recorded visit to this well when available.', width: 130, @@ -216,7 +217,7 @@ export const WellList: React.FC = () => { }, { field: 'contacts', - headerName: 'Contacts', + headerName: WellListColumnLabels.contacts, description: 'People or organizations linked to this well; open a contact from the link. Sort uses the alphabetically first linked contact name.', minWidth: 180, @@ -256,14 +257,14 @@ export const WellList: React.FC = () => { }, { field: 'well_completion_date', - headerName: 'Completed', + headerName: WellListColumnLabels.completed, description: 'Reported date the well construction was completed.', width: 130, valueGetter: (v: string) => formatAppDate(v), }, { field: 'well_driller_name', - headerName: 'Driller', + headerName: WellListColumnLabels.driller, description: 'Drilling company name when it was recorded for this well.', type: 'string', minWidth: 150, @@ -271,7 +272,7 @@ export const WellList: React.FC = () => { }, { field: 'latitude', - headerName: 'Latitude', + headerName: WellListColumnLabels.latitude, description: 'Latitude of the current mapped location in decimal degrees (WGS84).', type: 'number', @@ -284,7 +285,7 @@ export const WellList: React.FC = () => { }, { field: 'longitude', - headerName: 'Longitude', + headerName: WellListColumnLabels.longitude, description: 'Longitude of the current mapped location in decimal degrees (WGS84).', type: 'number', @@ -297,7 +298,7 @@ export const WellList: React.FC = () => { }, { field: 'alternate_ids', - headerName: 'Alternate IDs', + headerName: WellListColumnLabels.alternateIds, description: 'Identifiers from other agencies or programs that cross reference this well.', minWidth: 160, diff --git a/src/utils/layerExport.ts b/src/utils/layerExport.ts index a41e6487..14d66758 100644 --- a/src/utils/layerExport.ts +++ b/src/utils/layerExport.ts @@ -79,8 +79,19 @@ export const filterLayerFeaturesBySelection = ( }) } -export const buildLayerCsv = (features: any[]): string => { - const propertyKeys = [ +export type BuildLayerCsvOptions = { + /** + * Property keys to emit first when present; remaining keys are sorted alphabetically. + * Used so map exports match the preferred Wells list / map CSV column order. + */ + preferredPropertyColumnOrder?: string[] +} + +export const buildLayerCsv = ( + features: any[], + options?: BuildLayerCsvOptions +): string => { + const allPropertyKeys = [ ...new Set( features.flatMap((feature: any) => (Object.keys(feature?.properties || {}) as string[]).filter( @@ -88,7 +99,17 @@ export const buildLayerCsv = (features: any[]): string => { ) ) ), - ].sort() + ] + + const preferred = (options?.preferredPropertyColumnOrder ?? []).filter((key) => + allPropertyKeys.includes(key) + ) + const preferredSet = new Set(preferred) + const rest = allPropertyKeys + .filter((key) => !preferredSet.has(key)) + .sort() + + const propertyKeys = [...preferred, ...rest] const headers: string[] = [ ...propertyKeys, diff --git a/src/utils/wellMapExport.ts b/src/utils/wellMapExport.ts new file mode 100644 index 00000000..0de1010c --- /dev/null +++ b/src/utils/wellMapExport.ts @@ -0,0 +1,102 @@ +import type { CustomParams } from '@refinedev/core' +import type { IWellDetails } from '@/interfaces/ocotillo' +import { getFeatureId } from '@/utils/mapSelection' +import { + buildWellMapCsvEnrichmentFailedValues, + buildWellMapCsvValues, + dropMapCsvExcludedFeatureKeys, + stripLegacyDetailPrefixedKeys, +} from '@/well-export/wellMapCsvExport' + +export { buildWellShowAbsoluteUrl, getOcotilloPublicAppOrigin } from '@/utils/wellPublicUrls' + +type CustomGetter = (params: CustomParams) => Promise<{ data: unknown }> + +function ogcSiteNameFromProperties( + cleaned: Record +): string | number | undefined { + const v = cleaned.site_name + if (v === null || v === undefined) return undefined + if (typeof v === 'string' || typeof v === 'number') return v + return undefined +} + +/** + * @deprecated use buildWellMapCsvValues from @/well-export/wellMapCsvExport + */ +export function flattenWellDetailsForCsv(details: IWellDetails): Record { + return buildWellMapCsvValues(details) +} + +const DETAIL_FETCH_CONCURRENCY = 8 + +/** + * For map-export features with a numeric thing id, fetch Ocotillo well details and merge + * flattened columns into each feature's properties. + */ +export async function enrichMapFeaturesWithWellDetails( + features: any[], + customRequest: CustomGetter +): Promise { + const uniqueIds = [ + ...new Set( + features + .map((f) => getFeatureId(f)) + .filter((id): id is string => Boolean(id) && /^\d+$/.test(id)) + ), + ] + + const idToDetails = new Map() + + for (let i = 0; i < uniqueIds.length; i += DETAIL_FETCH_CONCURRENCY) { + const batch = uniqueIds.slice(i, i + DETAIL_FETCH_CONCURRENCY) + await Promise.all( + batch.map(async (id) => { + try { + const response = await customRequest({ + url: `thing/water-well/${id}/details`, + method: 'get', + }) + const data = response.data as IWellDetails + idToDetails.set(id, data) + } catch { + idToDetails.set(id, 'fail') + } + }) + ) + } + + return features.map((feature) => { + const id = getFeatureId(feature) + const raw = (feature.properties || {}) as Record + const cleaned = stripLegacyDetailPrefixedKeys(raw) + const ogcSiteName = ogcSiteNameFromProperties(cleaned) + const { site_name: _dropOgcSite, ...restNoOgcSite } = cleaned + const baseProps = dropMapCsvExcludedFeatureKeys(restNoOgcSite) + + if (!id || !/^\d+$/.test(id)) { + return { ...feature, properties: baseProps } + } + + const det = idToDetails.get(id) + if (det === 'fail') { + return { + ...feature, + properties: { + ...baseProps, + ...buildWellMapCsvEnrichmentFailedValues(id, { ogcSiteName }), + }, + } + } + if (det) { + return { + ...feature, + properties: { + ...baseProps, + ...buildWellMapCsvValues(det, { ogcSiteName }), + }, + } + } + return { ...feature, properties: baseProps } + }) +} diff --git a/src/utils/wellPublicUrls.ts b/src/utils/wellPublicUrls.ts new file mode 100644 index 00000000..493bd93b --- /dev/null +++ b/src/utils/wellPublicUrls.ts @@ -0,0 +1,11 @@ +/** Public web app origin used for deep links in exports (defaults to production). */ +export function getOcotilloPublicAppOrigin(): string { + const raw = import.meta.env.VITE_OCOTILLO_PUBLIC_APP_URL as string | undefined + const trimmed = raw?.trim().replace(/\/$/, '') + return trimmed || 'https://ocotillo.newmexicowaterdata.org' +} + +/** Canonical Ocotillo well detail URL for field workflows (production unless overridden). */ +export function buildWellShowAbsoluteUrl(thingId: string | number): string { + return `${getOcotilloPublicAppOrigin()}/ocotillo/well/show/${thingId}` +} diff --git a/src/well-export/wellMapCsvExport.ts b/src/well-export/wellMapCsvExport.ts new file mode 100644 index 00000000..39117529 --- /dev/null +++ b/src/well-export/wellMapCsvExport.ts @@ -0,0 +1,436 @@ +/** + * Map visible-layer CSV for water wells: column titles match the Wells list where the same + * field exists (`WellListColumnLabels`). Extra export-only headers use `WellMapCsvOnlyLabels`. + */ +import type { IWellDetails } from '@/interfaces/ocotillo' +import type { IWell } from '@/interfaces/ocotillo' +import type { IContact } from '@/interfaces/ocotillo' +import { formatAppDate } from '@/utils' +import { buildWellShowAbsoluteUrl } from '@/utils/wellPublicUrls' +import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' + +/** Map CSV columns that are not on the Wells list grid (or differ in shape, for example contact columns). */ +export const WellMapCsvOnlyLabels = { + wellDetailPage: 'Well detail page', + enrichmentFailed: 'Enrichment failed', + measuringPoint: 'Measuring Point', + contactName: 'Contact name', + email: 'Email', + phone: 'Phone', + monitoringFrequency: 'Monitoring Frequency', + measuredFor: 'Measured For', + lastVisitDate: 'Last Visit Date', + firstVisitStaff: 'First Visit Staff', + county: 'County', + state: 'State', + quadName: 'Quad name', + groups: 'Groups', + wellPurposes: 'Well purposes', + casingDiameter: 'Casing Diameter', + casingDepth: 'Casing Depth', + casingMaterials: 'Casing Materials', + pumpType: 'Pump Type', + pumpDepth: 'Pump Depth', + elevation: 'Elevation', + elevationMethod: 'Elevation Method', + verticalDatum: 'Vertical Datum', + dataloggerSuitability: 'Datalogger Suitability', + depthSource: 'Depth Source', + historicDepthToWater: 'Historic Depth to Water', + constructionMethod: 'Construction Method', + formationCompletionCode: 'Formation Completion Code', + aquiferTypes: 'Aquifer types', + measuringPointHeight: 'Measuring point height', + measuringPointDescription: 'Measuring point description', + utmZone: 'UTM zone', + utmEasting: 'UTM easting', + utmNorthing: 'UTM northing', + locationNotes: 'Location notes', + generalNotes: 'General notes', + siteNotes: 'Site notes', + constructionNotes: 'Construction notes', + waterNotes: 'Water notes', + permissions: 'Permissions', + fullWellJson: 'Full well JSON', +} as const + +/** OGC / map feature keys omitted from CSV (IDs and layer name duplicate other fields). */ +export const MAP_CSV_DROPPED_FEATURE_KEYS = [ + 'name', + 'id', + 'thing_name', + 'thing_id', + 'well_id', +] as const + +/** Reserved for optional leading columns from the map layer; empty means none. */ +export const MAP_CSV_OGC_LEADING_KEYS: readonly string[] = [] + +export const WELL_MAP_CSV_COLUMN_HEADERS_EXPLICIT: readonly string[] = [ + WellListColumnLabels.wellId, + WellListColumnLabels.name, + WellMapCsvOnlyLabels.wellDetailPage, + WellListColumnLabels.siteName, + WellListColumnLabels.holeDepthFt, + WellListColumnLabels.wellDepthFt, + WellMapCsvOnlyLabels.measuringPoint, + WellMapCsvOnlyLabels.contactName, + WellMapCsvOnlyLabels.email, + WellMapCsvOnlyLabels.phone, + WellListColumnLabels.wellStatus, + WellListColumnLabels.monitoring, + WellListColumnLabels.createdAt, + WellListColumnLabels.type, + WellListColumnLabels.aquifers, + WellListColumnLabels.releaseStatus, + WellMapCsvOnlyLabels.monitoringFrequency, + WellMapCsvOnlyLabels.measuredFor, + WellMapCsvOnlyLabels.lastVisitDate, + WellListColumnLabels.firstVisit, + WellMapCsvOnlyLabels.firstVisitStaff, + WellMapCsvOnlyLabels.county, + WellMapCsvOnlyLabels.state, + WellMapCsvOnlyLabels.quadName, + WellListColumnLabels.alternateIds, + WellMapCsvOnlyLabels.groups, + WellMapCsvOnlyLabels.wellPurposes, + WellMapCsvOnlyLabels.casingDiameter, + WellMapCsvOnlyLabels.casingDepth, + WellMapCsvOnlyLabels.casingMaterials, + WellMapCsvOnlyLabels.pumpType, + WellMapCsvOnlyLabels.pumpDepth, + WellMapCsvOnlyLabels.elevation, + WellMapCsvOnlyLabels.elevationMethod, + WellMapCsvOnlyLabels.verticalDatum, + WellMapCsvOnlyLabels.dataloggerSuitability, + WellListColumnLabels.driller, + WellListColumnLabels.latitude, + WellListColumnLabels.longitude, + WellMapCsvOnlyLabels.depthSource, + WellMapCsvOnlyLabels.historicDepthToWater, + WellListColumnLabels.completed, + WellMapCsvOnlyLabels.constructionMethod, + WellMapCsvOnlyLabels.measuringPointHeight, + WellMapCsvOnlyLabels.measuringPointDescription, + WellMapCsvOnlyLabels.formationCompletionCode, + WellMapCsvOnlyLabels.aquiferTypes, + WellMapCsvOnlyLabels.locationNotes, + WellMapCsvOnlyLabels.generalNotes, + WellMapCsvOnlyLabels.siteNotes, + WellMapCsvOnlyLabels.constructionNotes, + WellMapCsvOnlyLabels.waterNotes, + WellMapCsvOnlyLabels.permissions, + WellMapCsvOnlyLabels.utmZone, + WellMapCsvOnlyLabels.utmEasting, + WellMapCsvOnlyLabels.utmNorthing, + WellMapCsvOnlyLabels.fullWellJson, +] + +/** Full preferred CSV column order for visible features export (OGC keys, then well columns). */ +export function buildMapExportPreferredColumnOrder(): string[] { + return [...MAP_CSV_OGC_LEADING_KEYS, ...WELL_MAP_CSV_COLUMN_HEADERS_EXPLICIT] +} + +/** + * Site name for CSV: API `site_name`, else OGC map `site_name`, else text from + * Unknown-organization alternate IDs (same strings users used to read from "Alternate IDs"). + */ +export function deriveSiteNameColumn( + well: IWell, + ogcSiteName?: string | number | null +): string { + const fromApi = well.site_name?.trim() + if (fromApi) return fromApi + if (ogcSiteName != null && String(ogcSiteName).trim() !== '') { + return String(ogcSiteName).trim() + } + const unknownLabels = (well.alternate_ids ?? []) + .filter( + (link) => + (link.alternate_organization || '').toUpperCase().trim() === 'UNKNOWN' + ) + .map((link) => link.alternate_id?.trim()) + .filter(Boolean) as string[] + if (unknownLabels.length) return unknownLabels.join(' ; ') + return '' +} + +/** Remove legacy `detail_*` keys from older exports or cached features. */ +export function stripLegacyDetailPrefixedKeys( + properties: Record +): Record { + return Object.fromEntries( + Object.entries(properties).filter(([k]) => !k.startsWith('detail_')) + ) +} + +export function dropMapCsvExcludedFeatureKeys( + properties: Record +): Record { + const out = { ...properties } + for (const k of MAP_CSV_DROPPED_FEATURE_KEYS) { + delete out[k] + } + return out +} + +function formatMeasuringPointLikeCoreCard(well: IWell): string { + const parts = [ + well?.measuring_point_description?.trim() || null, + well?.measuring_point_height != null + ? `${well.measuring_point_height} ${well.measuring_point_height_unit ?? ''}`.trim() + : null, + ].filter(Boolean) + return parts.join(' | ') +} + +function measuredForLabel(firstVisitDate: string | null | undefined): string { + if (!firstVisitDate) return '' + const start = new Date(firstVisitDate) + if (Number.isNaN(start.getTime())) return '' + const now = new Date() + const totalMonths = + (now.getFullYear() - start.getFullYear()) * 12 + + (now.getMonth() - start.getMonth()) + if (totalMonths <= 0) return 'Less than a month' + const years = Math.floor(totalMonths / 12) + const months = totalMonths % 12 + if (years === 0) return `${months} month${months !== 1 ? 's' : ''}` + if (months === 0) return `${years} year${years !== 1 ? 's' : ''}` + return `${years} year${years !== 1 ? 's' : ''}, ${months} month${months !== 1 ? 's' : ''}` +} + +function formatMonitoringFrequencies(well: IWell): string { + const freqs = well.monitoring_frequencies ?? [] + if (freqs.length === 0) return '' + return freqs + .map((f) => { + const end = f.end_date ? ` - ${formatAppDate(f.end_date)}` : '' + return `${f.monitoring_frequency} ${formatAppDate(f.start_date)}${end}` + }) + .join(' ; ') +} + +function formatFirstVisitStaff(details: IWellDetails): string { + const parts = + details.first_field_event?.field_event_participants?.map((p) => { + const n = p.participant?.name || 'Unknown' + return p.participant_role ? `${n} (${p.participant_role})` : n + }) ?? [] + return parts.join(', ') +} + +function formatContactTriple(contacts: IContact[] | undefined): Record< + string, + string +> { + if (!contacts?.length) { + return { + [WellMapCsvOnlyLabels.contactName]: '', + [WellMapCsvOnlyLabels.email]: '', + [WellMapCsvOnlyLabels.phone]: '', + } + } + + const names = contacts + .map((c) => c.name?.trim() ?? '') + .filter(Boolean) + .join(', ') + + const emails = contacts + .map((c) => + (c.emails ?? []) + .map((e) => e.email?.trim()) + .filter(Boolean) + .join(', ') + ) + .filter(Boolean) + .join(', ') + + const phones = contacts + .map((c) => + (c.phones ?? []) + .map((p) => p.phone_number?.trim()) + .filter(Boolean) + .join(', ') + ) + .filter(Boolean) + .join(', ') + + return { + [WellMapCsvOnlyLabels.contactName]: names, + [WellMapCsvOnlyLabels.email]: emails, + [WellMapCsvOnlyLabels.phone]: phones, + } +} + +function stringifyNotes(well: IWell, key: keyof IWell): string { + const notes = well[key] + if (!Array.isArray(notes)) return '' + return notes + .map((n: { content?: string }) => n?.content) + .filter(Boolean) + .join(' | ') +} + +export type WellMapCsvBuildOptions = { + /** OGC / map feature `site_name` when API `site_name` is null */ + ogcSiteName?: string | number | null +} + +/** Values for one well row: keys are CSV headers. List-aligned titles use `WellListColumnLabels`. */ +export function buildWellMapCsvValues( + details: IWellDetails, + options?: WellMapCsvBuildOptions +): Record { + const { well, contacts } = details + const locProps = well.current_location?.properties as + | Record + | undefined + + const elevationNum = + typeof locProps?.elevation === 'number' ? locProps.elevation : null + const utm = locProps?.utm_coordinates as Record | undefined + + const lastVisit = details.field_events?.[0]?.event_date + + const holeDisplay = well.hole_depth + ? `${well.hole_depth} ${well.hole_depth_unit ?? ''}`.trim() + : '' + const wellDepthDisplay = well.well_depth + ? `${well.well_depth} ${well.well_depth_unit ?? ''}`.trim() + : '' + + const casingDiam = + well.well_casing_diameter != null + ? `${well.well_casing_diameter} ${well.well_casing_diameter_unit ?? ''}`.trim() + : '' + const casingDepth = + well.well_casing_depth != null + ? `${well.well_casing_depth} ${well.well_casing_depth_unit ?? ''}`.trim() + : '' + const pumpDepth = + well.well_pump_depth != null + ? `${well.well_pump_depth} ${well.well_pump_depth_unit ?? ''}`.trim() + : '' + + const elevDisplay = + elevationNum != null + ? `${elevationNum.toFixed(2)} ${String(locProps?.elevation_unit ?? '')}`.trim() + : '' + + const dataloggerVal = + (well as IWell & { datalogger_suitability_status?: string | null }) + .datalogger_suitability_status ?? + (well.is_suitable_for_datalogger != null + ? String(well.is_suitable_for_datalogger) + : '') + + const row: Record = { + [WellListColumnLabels.wellId]: String(well.id), + [WellListColumnLabels.name]: well.name ?? '', + [WellMapCsvOnlyLabels.wellDetailPage]: buildWellShowAbsoluteUrl(well.id), + [WellListColumnLabels.siteName]: deriveSiteNameColumn(well, options?.ogcSiteName), + [WellListColumnLabels.holeDepthFt]: holeDisplay, + [WellListColumnLabels.wellDepthFt]: wellDepthDisplay, + [WellMapCsvOnlyLabels.measuringPoint]: formatMeasuringPointLikeCoreCard(well), + ...formatContactTriple(contacts), + [WellListColumnLabels.wellStatus]: well.well_status ?? '', + [WellListColumnLabels.monitoring]: well.monitoring_status ?? '', + [WellListColumnLabels.createdAt]: formatAppDate(well.created_at) || '', + [WellListColumnLabels.type]: well.thing_type ?? '', + [WellListColumnLabels.aquifers]: (well.aquifers ?? []) + .map((a) => a.aquifer_system) + .filter(Boolean) + .join(', '), + [WellListColumnLabels.releaseStatus]: String(well.release_status ?? ''), + [WellMapCsvOnlyLabels.monitoringFrequency]: formatMonitoringFrequencies(well), + [WellMapCsvOnlyLabels.measuredFor]: measuredForLabel(well.first_visit_date), + [WellMapCsvOnlyLabels.lastVisitDate]: formatAppDate(lastVisit) || '', + [WellListColumnLabels.firstVisit]: formatAppDate(well.first_visit_date) || '', + [WellMapCsvOnlyLabels.firstVisitStaff]: formatFirstVisitStaff(details), + [WellMapCsvOnlyLabels.county]: String(locProps?.county ?? ''), + [WellMapCsvOnlyLabels.state]: String(locProps?.state ?? ''), + [WellMapCsvOnlyLabels.quadName]: String(locProps?.quad_name ?? ''), + [WellListColumnLabels.alternateIds]: (well.alternate_ids ?? []) + .map( + (link) => + `${link.alternate_organization ?? ''}: ${link.alternate_id ?? ''}` + ) + .join(', '), + [WellMapCsvOnlyLabels.groups]: (well.groups ?? []) + .map((g) => g.name) + .filter(Boolean) + .join('; '), + [WellMapCsvOnlyLabels.wellPurposes]: (well.well_purposes ?? []).join('; '), + [WellMapCsvOnlyLabels.casingDiameter]: casingDiam, + [WellMapCsvOnlyLabels.casingDepth]: casingDepth, + [WellMapCsvOnlyLabels.casingMaterials]: (well.well_casing_materials ?? []).join( + ', ' + ), + [WellMapCsvOnlyLabels.pumpType]: well.well_pump_type ?? '', + [WellMapCsvOnlyLabels.pumpDepth]: pumpDepth, + [WellMapCsvOnlyLabels.elevation]: elevDisplay, + [WellMapCsvOnlyLabels.elevationMethod]: String(locProps?.elevation_method ?? ''), + [WellMapCsvOnlyLabels.verticalDatum]: String(locProps?.vertical_datum ?? ''), + [WellMapCsvOnlyLabels.dataloggerSuitability]: dataloggerVal, + [WellListColumnLabels.driller]: well.well_driller_name ?? '', + [WellListColumnLabels.latitude]: + well.current_location?.geometry?.coordinates?.[1] != null + ? String(well.current_location.geometry.coordinates[1]) + : '', + [WellListColumnLabels.longitude]: + well.current_location?.geometry?.coordinates?.[0] != null + ? String(well.current_location.geometry.coordinates[0]) + : '', + [WellMapCsvOnlyLabels.depthSource]: well.well_depth_source ?? '', + [WellMapCsvOnlyLabels.historicDepthToWater]: + (well.historic_depth_to_water?.length ?? 0) > 0 + ? (well.historic_depth_to_water ?? []).join(', ') + : '', + [WellListColumnLabels.completed]: + formatAppDate(well.well_completion_date) || '', + [WellMapCsvOnlyLabels.constructionMethod]: well.well_construction_method ?? '', + [WellMapCsvOnlyLabels.measuringPointHeight]: String( + well.measuring_point_height ?? '' + ), + [WellMapCsvOnlyLabels.measuringPointDescription]: + well.measuring_point_description ?? '', + [WellMapCsvOnlyLabels.formationCompletionCode]: + well.formation_completion_code ?? '', + [WellMapCsvOnlyLabels.aquiferTypes]: + well.aquifers && well.aquifers.length > 0 + ? [...new Set(well.aquifers.flatMap((a) => a.aquifer_types))].join(', ') + : '', + [WellMapCsvOnlyLabels.locationNotes]: String(locProps?.nma_location_notes ?? ''), + [WellMapCsvOnlyLabels.generalNotes]: stringifyNotes(well, 'general_notes'), + [WellMapCsvOnlyLabels.siteNotes]: stringifyNotes(well, 'site_notes'), + [WellMapCsvOnlyLabels.constructionNotes]: stringifyNotes(well, 'construction_notes'), + [WellMapCsvOnlyLabels.waterNotes]: stringifyNotes(well, 'water_notes'), + [WellMapCsvOnlyLabels.permissions]: JSON.stringify(well.permissions ?? []), + [WellMapCsvOnlyLabels.utmZone]: utm?.utm_zone != null ? String(utm.utm_zone) : '', + [WellMapCsvOnlyLabels.utmEasting]: + utm?.easting != null ? String(utm.easting) : '', + [WellMapCsvOnlyLabels.utmNorthing]: + utm?.northing != null ? String(utm.northing) : '', + [WellMapCsvOnlyLabels.fullWellJson]: JSON.stringify(well), + } + + return row +} + +export function buildWellMapCsvEnrichmentFailedValues( + thingId: string, + options?: WellMapCsvBuildOptions +): Record { + const fromOgc = + options?.ogcSiteName != null && String(options.ogcSiteName).trim() !== '' + ? String(options.ogcSiteName).trim() + : '' + return { + [WellListColumnLabels.wellId]: thingId, + [WellMapCsvOnlyLabels.wellDetailPage]: buildWellShowAbsoluteUrl(thingId), + [WellMapCsvOnlyLabels.enrichmentFailed]: 'Yes', + [WellListColumnLabels.siteName]: fromOgc, + } +} diff --git a/src/well-list/wellListColumnLabels.ts b/src/well-list/wellListColumnLabels.ts new file mode 100644 index 00000000..e37ac51f --- /dev/null +++ b/src/well-list/wellListColumnLabels.ts @@ -0,0 +1,24 @@ +/** + * Column titles for the Ocotillo water well list (`thing/list.tsx` DataGrid). + * Map visible-layer CSV uses the same strings for the same fields so exports match the grid. + */ +export const WellListColumnLabels = { + wellId: 'Well ID', + name: 'Name', + siteName: 'Site name', + monitoring: 'Monitoring', + createdAt: 'Created At', + wellStatus: 'Well Status', + type: 'Type', + aquifers: 'Aquifers', + releaseStatus: 'Release Status', + wellDepthFt: 'Well Depth (ft)', + holeDepthFt: 'Hole Depth (ft)', + firstVisit: 'First Visit', + contacts: 'Contacts', + completed: 'Completed', + driller: 'Driller', + latitude: 'Latitude', + longitude: 'Longitude', + alternateIds: 'Alternate IDs', +} as const