diff --git a/CHANGELOG.md b/CHANGELOG.md index 10239dc8..7ba92016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 39 Frontend orchestration decomposition (2026-06-17) + +- Moved provider capability loading from `App.tsx` into `frontend/src/hooks/useProviderCapabilities.ts`. +- Moved change detection state and API orchestration into `frontend/src/hooks/useChangeDetectionWorkflow.ts`. +- Moved derived MapLibre workbench state, feature collection selection and feature-inspector reset behavior into `frontend/src/hooks/useMapWorkspaceState.ts`. +- Added regression tests that keep provider/change/map orchestration out of `App.tsx`. +- No API contracts, migrations, product features, provider fetching or AI behavior were introduced. + ## Sprint 38 Export Center preview hardening (2026-06-17) - Prevented HTML project report artifacts from being offered through the JSON preview path in the frontend Export Center. diff --git a/backend/tests/test_sprint39_frontend_orchestration_hooks.py b/backend/tests/test_sprint39_frontend_orchestration_hooks.py new file mode 100644 index 00000000..204cc63f --- /dev/null +++ b/backend/tests/test_sprint39_frontend_orchestration_hooks.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_app_uses_shared_orchestration_hooks() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "useProviderCapabilities" in app + assert "useChangeDetectionWorkflow" in app + assert "useMapWorkspaceState" in app + assert "from './hooks/useProviderCapabilities'" in app + assert "from './hooks/useChangeDetectionWorkflow'" in app + assert "from './hooks/useMapWorkspaceState'" in app + assert "analysisApi" not in app + assert "externalApi" not in app + + +def test_provider_capabilities_hook_owns_provider_api_calls() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useProviderCapabilities.ts").read_text(encoding="utf-8") + + assert "externalApi.listProviders" in hook + assert "loadCapabilities" in hook + assert "loadingCapabilities" in hook + assert "capabilitiesError" in hook + + +def test_change_detection_hook_owns_change_detection_api_calls() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useChangeDetectionWorkflow.ts").read_text(encoding="utf-8") + + assert "analysisApi.runChangeDetection" in hook + assert "runChangeDetection" in hook + assert "changeDetectionResult" in hook + assert "loadDatasetJobs" in hook + + +def test_map_workspace_state_hook_owns_derived_map_state() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useMapWorkspaceState.ts").read_text(encoding="utf-8") + + assert "areaFeatureCollection" in hook + assert "mapFeatureCollection" in hook + assert "mapLayerLabel" in hook + assert "setSelectedMapFeature(null)" in hook + diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 86a5c2bd..051a33d8 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,27 @@ +## Sprint 39 Frontend orchestration decomposition (2026-06-17) + +Changed: +- Moved provider capability loading state and `externalApi.listProviders` calls into `frontend/src/hooks/useProviderCapabilities.ts`. +- Moved change-detection state, validation and `analysisApi.runChangeDetection` calls into `frontend/src/hooks/useChangeDetectionWorkflow.ts`. +- Moved map-layer derived state, area GeoJSON feature construction and selected-feature reset behavior into `frontend/src/hooks/useMapWorkspaceState.ts`. +- Kept `App.tsx` as the cross-module composition layer without changing panel props, API contracts, migrations or product behavior. +- Added static regression tests for the extracted orchestration hooks. +- Updated frontend README, changelog and TODO status. + +Validation: +- `cd backend && python -m pytest tests/test_sprint39_frontend_orchestration_hooks.py -q` passed: 4 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` passed. + +Open: +- Full readiness and Tower deploy smoke should run before considering this pass deployed. + +Limitations: +- Project/area/dataset cross-load orchestration still lives in `App.tsx`; it is a good next low-risk decomposition target. + +Next recommended pass: +- Run full readiness and Tower deploy verification, then extract project/area loading into a dedicated hook if behavior remains stable. + ## Sprint 38 Export Center preview hardening (2026-06-17) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 2cbad5c2..6147a32e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -51,7 +51,8 @@ This file now starts with the current implementation status. Older preparation/b - [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid. - [x] Single-container `geointel` Unraid compose/template runtime. - [x] Export preview component decomposition and HTML report download-only UX hardening. -- [ ] Further frontend shared workbench orchestration decomposition. +- [x] Provider, change-detection and map-workspace orchestration hook decomposition. +- [ ] Further project/area/dataset cross-load orchestration decomposition. ## Sprint 8 status diff --git a/frontend/README.md b/frontend/README.md index 599d8fbb..50dddbe2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -210,6 +210,9 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow. - The MapLibre chunk is intentionally larger than generic app chunks because it contains the GIS map runtime; the Vite warning threshold is set to keep this known vendor dependency visible without warning on every release build. - Export preview rendering lives in `src/components/exports/ExportPreview.tsx`. - The Export Center only offers JSON preview for JSON/GeoJSON artifacts; HTML project reports are shown as download-only artifacts. +- Provider capability loading lives in `src/hooks/useProviderCapabilities.ts`. +- Change detection orchestration lives in `src/hooks/useChangeDetectionWorkflow.ts`. +- Derived MapLibre workbench state lives in `src/hooks/useMapWorkspaceState.ts`. ## Raster dependency visibility diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e40a08a..4cba3ab3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import './styles/app.css' import { areasApi } from './services/api/areas' import { datasetsApi } from './services/api/datasets' import { projectsApi } from './services/api/projects' -import { analysisApi, demoApi, externalApi } from './services/api' +import { demoApi } from './services/api' import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel' import { DatasetPanel } from './components/datasets/DatasetPanel' @@ -16,8 +16,6 @@ import { ProjectPanel } from './components/project/ProjectPanel' import { QualityResultsPanel } from './components/quality/QualityResultsPanel' import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip' import type { - ApiError, - ChangeDetectionSummary, DatasetCreateResponse, DatasetListResponse, ProjectRead, @@ -25,13 +23,15 @@ import type { AreaCreate, AreaListResponse, AreaRead, - ProviderCapability, } from './types' import { ProviderPanel } from './components/providers/ProviderPanel' import { SegmentationLab } from './components/segmentation/SegmentationLab' +import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow' import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' import { useExportWorkflow } from './hooks/useExportWorkflow' +import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' +import { useProviderCapabilities } from './hooks/useProviderCapabilities' import { useQualityWorkflow } from './hooks/useQualityWorkflow' import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow' import { formatError } from './lib/formatError' @@ -45,22 +45,6 @@ function App(): JSX.Element { const [selectedProjectId, setSelectedProjectId] = useState(null) const [areas, setAreas] = useState([]) const [datasets, setDatasets] = useState([]) - const [mapLayerVisible, setMapLayerVisible] = useState(true) - const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4) - const [selectedMapAreaId, setSelectedMapAreaId] = useState('') - const [areaLayerVisible, setAreaLayerVisible] = useState(true) - const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18) - const [selectedMapFeature, setSelectedMapFeature] = useState(null) - const [providerCapabilities, setProviderCapabilities] = useState([]) - const [loadingCapabilities, setLoadingCapabilities] = useState(false) - const [capabilitiesError, setCapabilitiesError] = useState(null) - const [changeSourceDatasetId, setChangeSourceDatasetId] = useState('') - const [changeTargetDatasetId, setChangeTargetDatasetId] = useState('') - const [changeIouThreshold, setChangeIouThreshold] = useState(0.8) - const [changeIncludeUnchanged, setChangeIncludeUnchanged] = useState(true) - const [runningChangeDetection, setRunningChangeDetection] = useState(false) - const [changeDetectionResult, setChangeDetectionResult] = useState(null) - const [changeDetectionError, setChangeDetectionError] = useState(null) const [loadingProjects, setLoadingProjects] = useState(false) const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false) const [demoWorkflowMessage, setDemoWorkflowMessage] = useState(null) @@ -171,8 +155,31 @@ function App(): JSX.Element { [availableVectorDatasets], ) const candidateDatasets = availableVectorDatasets - const providers = useMemo(() => providerCapabilities, [providerCapabilities]) const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets]) + const { + providers, + loadingCapabilities, + capabilitiesError, + loadCapabilities, + } = useProviderCapabilities() + const { + changeSourceDatasetId, + changeTargetDatasetId, + changeIouThreshold, + changeIncludeUnchanged, + runningChangeDetection, + changeDetectionResult, + changeDetectionError, + runChangeDetection, + setChangeSourceDatasetId, + setChangeTargetDatasetId, + setChangeIouThreshold, + setChangeIncludeUnchanged, + } = useChangeDetectionWorkflow({ + selectedProjectId, + availableVectorDatasets, + loadDatasetJobs, + }) const { qaCandidateDatasetId, qaReferenceDatasetId, @@ -301,52 +308,32 @@ function App(): JSX.Element { selectedSegmentationRunId, isVectorDatasetType, }) - const selectedMapArea = useMemo( - () => areas.find((area) => area.id === selectedMapAreaId) ?? null, - [areas, selectedMapAreaId], - ) - const areaFeatureCollection = useMemo(() => { - if (!selectedMapArea?.geometry) { - return null - } - return { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - geometry: selectedMapArea.geometry, - properties: { - layer_type: 'project_area', - area_id: selectedMapArea.id, - name: selectedMapArea.name, - area_m2: selectedMapArea.area_m2 ?? null, - original_crs: selectedMapArea.original_crs ?? null, - }, - }, - ], - } - }, [selectedMapArea]) - const mapFeatureCollection = useMemo( - () => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent, - [changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent], - ) - const mapLayerLabel = useMemo(() => { - if (changeDetectionResult) { - return 'Change detection result' - } - if (segmentationGeoJson) { - return 'Segmentation result' - } - if (detectionGeoJson) { - return 'Detection result' - } - if (datasetContent && selectedDataset) { - return selectedDataset.name - } - return 'No active vector layer' - }, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset]) - const mapFeatureCount = mapFeatureCollection?.features.length ?? 0 - const areaFeatureCount = areaFeatureCollection?.features.length ?? 0 + const { + mapLayerVisible, + mapLayerOpacity, + selectedMapAreaId, + areaLayerVisible, + areaLayerOpacity, + selectedMapFeature, + areaFeatureCollection, + mapFeatureCollection, + mapLayerLabel, + mapFeatureCount, + areaFeatureCount, + setMapLayerVisible, + setMapLayerOpacity, + setSelectedMapAreaId, + setAreaLayerVisible, + setAreaLayerOpacity, + setSelectedMapFeature, + } = useMapWorkspaceState({ + areas, + changeDetectionGeoJson: changeDetectionResult?.geojson ?? null, + segmentationGeoJson, + detectionGeoJson, + datasetContent, + selectedDataset, + }) const loadProjects = async () => { setLoadingProjects(true) setErrorMessage(null) @@ -394,19 +381,6 @@ function App(): JSX.Element { } } - const loadCapabilities = async () => { - setLoadingCapabilities(true) - setCapabilitiesError(null) - try { - const providerResponse = await externalApi.listProviders() - setProviderCapabilities(providerResponse.providers) - } catch (error) { - setCapabilitiesError(error instanceof Error ? error.message : 'Failed to load external capabilities') - } finally { - setLoadingCapabilities(false) - } - } - useEffect(() => { loadProjects().catch(() => null) loadCapabilities().catch(() => null) @@ -414,10 +388,6 @@ function App(): JSX.Element { loadSegmentationModels().catch(() => null) }, []) - useEffect(() => { - setSelectedMapFeature(null) - }, [mapFeatureCollection, areaFeatureCollection]) - useEffect(() => { if (!selectedProjectId) { setAreas([]) @@ -524,51 +494,6 @@ function App(): JSX.Element { } } - const runChangeDetection = async () => { - const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id - const targetDatasetId = - changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id - if (!sourceDatasetId || !targetDatasetId) { - setChangeDetectionError('Select two vector datasets') - return - } - if (sourceDatasetId === targetDatasetId) { - setChangeDetectionError('Source and target datasets must differ') - return - } - if (changeIouThreshold < 0 || changeIouThreshold > 1) { - setChangeDetectionError('IoU threshold must be between 0 and 1') - return - } - setChangeDetectionError(null) - setChangeDetectionResult(null) - setRunningChangeDetection(true) - try { - const job = await analysisApi.runChangeDetection({ - source_dataset_id: sourceDatasetId, - target_dataset_id: targetDatasetId, - iou_threshold: changeIouThreshold, - include_unchanged: changeIncludeUnchanged, - }) - if (job.status !== 'success') { - throw new Error(job.error_message || 'Change detection job failed') - } - if (!job.result_json) { - throw new Error('Change detection completed without result payload') - } - setChangeSourceDatasetId(sourceDatasetId) - setChangeTargetDatasetId(targetDatasetId) - setChangeDetectionResult(job.result_json) - if (selectedProjectId) { - await loadDatasetJobs(selectedProjectId, sourceDatasetId) - } - } catch (error) { - setChangeDetectionError(formatError(error, 'Change detection failed')) - } finally { - setRunningChangeDetection(false) - } - } - return (
diff --git a/frontend/src/hooks/useChangeDetectionWorkflow.ts b/frontend/src/hooks/useChangeDetectionWorkflow.ts new file mode 100644 index 00000000..9f41fcf2 --- /dev/null +++ b/frontend/src/hooks/useChangeDetectionWorkflow.ts @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { analysisApi } from '../services/api' +import type { ChangeDetectionSummary, DatasetCreateResponse } from '../types' +import { formatError } from '../lib/formatError' + +interface ChangeDetectionWorkflowOptions { + selectedProjectId: string | null + availableVectorDatasets: DatasetCreateResponse[] + loadDatasetJobs: (projectId: string, datasetId: string) => Promise +} + +export function useChangeDetectionWorkflow({ + selectedProjectId, + availableVectorDatasets, + loadDatasetJobs, +}: ChangeDetectionWorkflowOptions) { + const [changeSourceDatasetId, setChangeSourceDatasetId] = useState('') + const [changeTargetDatasetId, setChangeTargetDatasetId] = useState('') + const [changeIouThreshold, setChangeIouThreshold] = useState(0.8) + const [changeIncludeUnchanged, setChangeIncludeUnchanged] = useState(true) + const [runningChangeDetection, setRunningChangeDetection] = useState(false) + const [changeDetectionResult, setChangeDetectionResult] = useState(null) + const [changeDetectionError, setChangeDetectionError] = useState(null) + + const runChangeDetection = async () => { + const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id + const targetDatasetId = + changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id + if (!sourceDatasetId || !targetDatasetId) { + setChangeDetectionError('Select two vector datasets') + return + } + if (sourceDatasetId === targetDatasetId) { + setChangeDetectionError('Source and target datasets must differ') + return + } + if (changeIouThreshold < 0 || changeIouThreshold > 1) { + setChangeDetectionError('IoU threshold must be between 0 and 1') + return + } + setChangeDetectionError(null) + setChangeDetectionResult(null) + setRunningChangeDetection(true) + try { + const job = await analysisApi.runChangeDetection({ + source_dataset_id: sourceDatasetId, + target_dataset_id: targetDatasetId, + iou_threshold: changeIouThreshold, + include_unchanged: changeIncludeUnchanged, + }) + if (job.status !== 'success') { + throw new Error(job.error_message || 'Change detection job failed') + } + if (!job.result_json) { + throw new Error('Change detection completed without result payload') + } + setChangeSourceDatasetId(sourceDatasetId) + setChangeTargetDatasetId(targetDatasetId) + setChangeDetectionResult(job.result_json) + if (selectedProjectId) { + await loadDatasetJobs(selectedProjectId, sourceDatasetId) + } + } catch (error) { + setChangeDetectionError(formatError(error, 'Change detection failed')) + } finally { + setRunningChangeDetection(false) + } + } + + return { + changeSourceDatasetId, + changeTargetDatasetId, + changeIouThreshold, + changeIncludeUnchanged, + runningChangeDetection, + changeDetectionResult, + changeDetectionError, + runChangeDetection, + setChangeSourceDatasetId, + setChangeTargetDatasetId, + setChangeIouThreshold, + setChangeIncludeUnchanged, + } +} diff --git a/frontend/src/hooks/useMapWorkspaceState.ts b/frontend/src/hooks/useMapWorkspaceState.ts new file mode 100644 index 00000000..df7bd8d0 --- /dev/null +++ b/frontend/src/hooks/useMapWorkspaceState.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from 'react' +import type { AreaRead, DatasetCreateResponse } from '../types' + +interface MapWorkspaceStateOptions { + areas: AreaRead[] + changeDetectionGeoJson: GeoJSON.FeatureCollection | null + segmentationGeoJson: GeoJSON.FeatureCollection | null + detectionGeoJson: GeoJSON.FeatureCollection | null + datasetContent: GeoJSON.FeatureCollection | null + selectedDataset: DatasetCreateResponse | null +} + +export function useMapWorkspaceState({ + areas, + changeDetectionGeoJson, + segmentationGeoJson, + detectionGeoJson, + datasetContent, + selectedDataset, +}: MapWorkspaceStateOptions) { + const [mapLayerVisible, setMapLayerVisible] = useState(true) + const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4) + const [selectedMapAreaId, setSelectedMapAreaId] = useState('') + const [areaLayerVisible, setAreaLayerVisible] = useState(true) + const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18) + const [selectedMapFeature, setSelectedMapFeature] = useState(null) + + const selectedMapArea = useMemo( + () => areas.find((area) => area.id === selectedMapAreaId) ?? null, + [areas, selectedMapAreaId], + ) + const areaFeatureCollection = useMemo(() => { + if (!selectedMapArea?.geometry) { + return null + } + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: selectedMapArea.geometry, + properties: { + layer_type: 'project_area', + area_id: selectedMapArea.id, + name: selectedMapArea.name, + area_m2: selectedMapArea.area_m2 ?? null, + original_crs: selectedMapArea.original_crs ?? null, + }, + }, + ], + } + }, [selectedMapArea]) + const mapFeatureCollection = useMemo( + () => changeDetectionGeoJson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent, + [changeDetectionGeoJson, segmentationGeoJson, detectionGeoJson, datasetContent], + ) + const mapLayerLabel = useMemo(() => { + if (changeDetectionGeoJson) { + return 'Change detection result' + } + if (segmentationGeoJson) { + return 'Segmentation result' + } + if (detectionGeoJson) { + return 'Detection result' + } + if (datasetContent && selectedDataset) { + return selectedDataset.name + } + return 'No active vector layer' + }, [changeDetectionGeoJson, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset]) + const mapFeatureCount = mapFeatureCollection?.features.length ?? 0 + const areaFeatureCount = areaFeatureCollection?.features.length ?? 0 + + useEffect(() => { + setSelectedMapFeature(null) + }, [mapFeatureCollection, areaFeatureCollection]) + + return { + mapLayerVisible, + mapLayerOpacity, + selectedMapAreaId, + areaLayerVisible, + areaLayerOpacity, + selectedMapFeature, + areaFeatureCollection, + mapFeatureCollection, + mapLayerLabel, + mapFeatureCount, + areaFeatureCount, + setMapLayerVisible, + setMapLayerOpacity, + setSelectedMapAreaId, + setAreaLayerVisible, + setAreaLayerOpacity, + setSelectedMapFeature, + } +} diff --git a/frontend/src/hooks/useProviderCapabilities.ts b/frontend/src/hooks/useProviderCapabilities.ts new file mode 100644 index 00000000..2d5213e6 --- /dev/null +++ b/frontend/src/hooks/useProviderCapabilities.ts @@ -0,0 +1,31 @@ +import { useMemo, useState } from 'react' +import { externalApi } from '../services/api' +import type { ProviderCapability } from '../types' + +export function useProviderCapabilities() { + const [providerCapabilities, setProviderCapabilities] = useState([]) + const [loadingCapabilities, setLoadingCapabilities] = useState(false) + const [capabilitiesError, setCapabilitiesError] = useState(null) + + const providers = useMemo(() => providerCapabilities, [providerCapabilities]) + + const loadCapabilities = async () => { + setLoadingCapabilities(true) + setCapabilitiesError(null) + try { + const providerResponse = await externalApi.listProviders() + setProviderCapabilities(providerResponse.providers) + } catch (error) { + setCapabilitiesError(error instanceof Error ? error.message : 'Failed to load external capabilities') + } finally { + setLoadingCapabilities(false) + } + } + + return { + providers, + loadingCapabilities, + capabilitiesError, + loadCapabilities, + } +}