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