diff --git a/docs/plans/replacement.md b/docs/plans/replacement.md
new file mode 100644
index 000000000..c01141ba6
--- /dev/null
+++ b/docs/plans/replacement.md
@@ -0,0 +1 @@
+Okay, we want to add an additional replacement pixel. So for every time we select a pixel, we want to take one pixel that falls within the ring of the quad key. So basically, you know, the one right above, below, to the right, to the left, or to like top right, you know, northeast, southeast, etc. So kind of like the nine, sorry, one, two, three, four, five, six, seven. The eight pixels that go around a pixel are candidates. Then you would randomly select one of those. The only criteria would be that that that has the necessary amount of buildings. So five buildings or whatever the threshold is, you pick one of those, and then we're going to make that a replacement sample in the coverage database. And then, so we have to mark that separately, and then, second, we will make it blue so it's visible as a different color option. And then the idea is they can then pick that as a replacement if the primary one is uh not does it provide enough children.
diff --git a/truecover-app/src/components/AddCampaignAreaModal.tsx b/truecover-app/src/components/AddCampaignAreaModal.tsx
index dce2cae22..80daaac6b 100644
--- a/truecover-app/src/components/AddCampaignAreaModal.tsx
+++ b/truecover-app/src/components/AddCampaignAreaModal.tsx
@@ -19,6 +19,7 @@ interface AddCampaignAreaModalProps {
name: string;
} | null;
drawnGeometry?: any;
+ existingBuildingCount?: number;
onAreaAdded?: (result?: { areaId: string; buildingWorkflowId?: string }) => void;
}
@@ -29,6 +30,7 @@ const AddCampaignAreaModal: React.FC = ({
mode,
adminBoundary,
drawnGeometry,
+ existingBuildingCount = 0,
onAreaAdded
}) => {
const { getToken } = useAuth();
@@ -177,6 +179,14 @@ const AddCampaignAreaModal: React.FC = ({
+ {existingBuildingCount > 0 && (
+
+
+ This area already has {existingBuildingCount.toLocaleString()} buildings imported.
+
+
+ )}
+
= ({
className="w-4 h-4"
/>
- Extract buildings from Overture Maps
+ {existingBuildingCount > 0
+ ? 'Re-extract buildings from Overture Maps'
+ : 'Extract buildings from Overture Maps'}
- Import building footprints from Overture Maps for this area
+ {existingBuildingCount > 0
+ ? 'This will re-import building footprints (duplicates are skipped)'
+ : 'Import building footprints from Overture Maps for this area'}
diff --git a/truecover-app/src/components/CampaignAreasManager.tsx b/truecover-app/src/components/CampaignAreasManager.tsx
index d06210c59..210d9f96b 100644
--- a/truecover-app/src/components/CampaignAreasManager.tsx
+++ b/truecover-app/src/components/CampaignAreasManager.tsx
@@ -68,6 +68,7 @@ const CampaignAreasManager: React.FC = ({
const [isDeleting, setIsDeleting] = useState(false);
const [areaToEdit, setAreaToEdit] = useState(null);
const [sampleCount, setSampleCount] = useState(50);
+ const [buildingsPerPixel, setBuildingsPerPixel] = useState(5);
const [selectedRoundId, setSelectedRoundId] = useState('');
const [sampleTarget, setSampleTarget] = useState('pixels');
const [isSampling, setIsSampling] = useState(false);
@@ -296,7 +297,8 @@ const CampaignAreasManager: React.FC = ({
sample_count: sampleCount,
resample,
round_id: selectedRoundId,
- sample_target: sampleTarget
+ sample_target: sampleTarget,
+ ...(sampleTarget === 'pixels' && buildingsPerPixel > 0 ? { buildings_per_pixel: buildingsPerPixel } : {}),
},
token
);
@@ -765,6 +767,25 @@ const CampaignAreasManager: React.FC = ({
className="w-full px-3 py-2 bg-tactical-bg-tertiary border border-tactical-border-medium text-tactical-text-primary font-mono text-sm focus:border-tactical-accent-primary focus:outline-none"
/>
+
+ {/* Buildings per Pixel (only for pixel sampling) */}
+ {sampleTarget === 'pixels' && (
+
+
+ Buildings per Pixel
+
+
setBuildingsPerPixel(Math.max(0, parseInt(e.target.value) || 0))}
+ min={0}
+ className="w-full px-3 py-2 bg-tactical-bg-tertiary border border-tactical-border-medium text-tactical-text-primary font-mono text-sm focus:border-tactical-accent-primary focus:outline-none"
+ />
+
+ {buildingsPerPixel === 0 ? 'No building sampling' : `Up to ${buildingsPerPixel} buildings selected per sampled pixel`}
+
+
+ )}
)}
diff --git a/truecover-app/src/components/CreateRoundModal.tsx b/truecover-app/src/components/CreateRoundModal.tsx
index 109625b08..1dace85dd 100644
--- a/truecover-app/src/components/CreateRoundModal.tsx
+++ b/truecover-app/src/components/CreateRoundModal.tsx
@@ -1,9 +1,12 @@
+// ABOUTME: Modal for creating a sampling round with optional per-area auto-sampling
+// ABOUTME: Creates a round record and optionally starts CampaignAreaSamplingWorkflow per selected area
+
import React, { useState, useEffect } from 'react';
import { TacticalModal, TacticalInput, TacticalButton, TacticalTextarea, TacticalSelect, TacticalDatePicker, tacticalToast } from '../tactical-ui';
import axios from 'axios';
import { useAuth } from '@clerk/clerk-react';
import { useIndicators } from '../hooks/useIndicators';
-import { usePixelMetadataStats } from '../hooks/usePixels';
+import { CampaignArea } from '../hooks/useCampaignAreas';
import { env } from '../config/env';
const API_URL = env.VITE_API_URL;
@@ -14,8 +17,8 @@ interface CreateRoundModalProps {
campaignId: string;
projectId: string;
onRoundCreated: () => void;
- adminBoundaryPcode?: string;
- adminBoundaryName?: string;
+ campaignAreas?: CampaignArea[];
+ onSamplingStarted?: (areaWorkflowMap: Record) => void;
}
const CreateRoundModal: React.FC = ({
@@ -24,42 +27,21 @@ const CreateRoundModal: React.FC = ({
campaignId,
projectId,
onRoundCreated,
- adminBoundaryPcode,
- adminBoundaryName,
+ campaignAreas = [],
+ onSamplingStarted,
}) => {
const { getToken } = useAuth();
const { data: indicators } = useIndicators(projectId);
- const { data: metadataStats } = usePixelMetadataStats(campaignId);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedIndicatorId, setSelectedIndicatorId] = useState('');
- const [batchSize, setBatchSize] = useState('10');
- const [uncertaintyField, setUncertaintyField] = useState('prevalence_bci_width');
- const [allowRevisit, setAllowRevisit] = useState(false);
- const [samplingTarget, setSamplingTarget] = useState<'locations' | 'pixels'>('locations');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
- const [enablePopulationFilter, setEnablePopulationFilter] = useState(false);
- const [minPopulation, setMinPopulation] = useState('10');
- const [populationField, setPopulationField] = useState('');
-
- // Get numeric metadata fields that could be used for population filtering
- const numericMetadataFields = (metadataStats?.metadata_fields || []).filter(
- (field: any) => field.data_type === 'integer' || field.data_type === 'float'
- );
- // Auto-select population field if it exists, otherwise first numeric field
- useEffect(() => {
- if (numericMetadataFields.length > 0 && !populationField) {
- // Prefer a field named "population" (case-insensitive)
- const populationFieldMatch = numericMetadataFields.find(
- (field: any) => field.name.toLowerCase() === 'population'
- );
- setPopulationField(populationFieldMatch?.name || numericMetadataFields[0].name);
- }
- }, [numericMetadataFields, populationField]);
+ // Per-area selection: area_id -> sampling config
+ const [selectedAreas, setSelectedAreas] = useState>(new Map());
// Auto-select first indicator when indicators load
useEffect(() => {
@@ -68,6 +50,40 @@ const CreateRoundModal: React.FC = ({
}
}, [indicators]);
+ const toggleArea = (areaId: string) => {
+ setSelectedAreas(prev => {
+ const next = new Map(prev);
+ if (next.has(areaId)) {
+ next.delete(areaId);
+ } else {
+ next.set(areaId, { sampleCount: 50, buildingsPerPixel: 5 });
+ }
+ return next;
+ });
+ };
+
+ const updateSampleCount = (areaId: string, count: number) => {
+ setSelectedAreas(prev => {
+ const next = new Map(prev);
+ const current = next.get(areaId);
+ if (current) {
+ next.set(areaId, { ...current, sampleCount: count });
+ }
+ return next;
+ });
+ };
+
+ const updateBuildingsPerPixel = (areaId: string, count: number) => {
+ setSelectedAreas(prev => {
+ const next = new Map(prev);
+ const current = next.get(areaId);
+ if (current) {
+ next.set(areaId, { ...current, buildingsPerPixel: count });
+ }
+ return next;
+ });
+ };
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -82,17 +98,19 @@ const CreateRoundModal: React.FC = ({
return;
}
- const batchSizeNum = parseInt(batchSize);
- if (isNaN(batchSizeNum) || batchSizeNum < 1) {
- setError('Batch size must be a number greater than 0');
- return;
- }
-
setIsSubmitting(true);
try {
const token = await getToken();
+ // Build sample_areas array from checked areas
+ const sampleAreas = Array.from(selectedAreas.entries()).map(([areaId, config]) => ({
+ area_id: areaId,
+ sample_count: config.sampleCount,
+ sample_target: 'pixels',
+ buildings_per_pixel: config.buildingsPerPixel,
+ }));
+
const response = await axios.post(
`${API_URL}/api/campaigns/${campaignId}/rounds`,
{
@@ -101,17 +119,7 @@ const CreateRoundModal: React.FC = ({
start_date: startDate || null,
end_date: endDate || null,
indicator_id: selectedIndicatorId,
- batch_size: batchSizeNum,
- uncertainty_field: uncertaintyField,
- allow_revisit: allowRevisit,
- sampling_target: samplingTarget,
- admin_pcode: adminBoundaryPcode || null,
- min_population: (samplingTarget === 'pixels' && enablePopulationFilter && populationField)
- ? parseFloat(minPopulation)
- : null,
- population_field: (samplingTarget === 'pixels' && enablePopulationFilter && populationField)
- ? populationField
- : null,
+ ...(sampleAreas.length > 0 ? { sample_areas: sampleAreas } : {}),
},
{
headers: {
@@ -121,9 +129,8 @@ const CreateRoundModal: React.FC = ({
}
);
- // Temporal workflow started - close modal immediately
- if (response.status === 202 && response.data.workflow_id) {
- const workflowId = response.data.workflow_id;
+ if (response.status === 201) {
+ const workflowIds = response.data.workflow_ids || {};
const roundName = name.trim();
// Reset form
@@ -132,58 +139,25 @@ const CreateRoundModal: React.FC = ({
setStartDate('');
setEndDate('');
setSelectedIndicatorId(indicators && indicators.length > 0 ? indicators[0].id : '');
- setBatchSize('10');
- setUncertaintyField('prevalence_bci_width');
- setAllowRevisit(false);
- setSamplingTarget('locations');
- setEnablePopulationFilter(false);
- setMinPopulation('10');
- setPopulationField(numericMetadataFields.length > 0 ? numericMetadataFields[0].name : '');
-
- // Close modal immediately
- onClose();
+ setSelectedAreas(new Map());
- // Show info toast that round generation started
- tacticalToast.info(
- 'Round Generation Started',
- `Generating round "${roundName}"...`
- );
-
- // Poll for completion
- const pollInterval = setInterval(async () => {
- try {
- const statusResponse = await axios.get(
- `${API_URL}/api/rounds/workflow/${workflowId}/status`,
- {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- }
- );
-
- if (statusResponse.data.status === 'completed' && statusResponse.data.result) {
- clearInterval(pollInterval);
- const selectedCount = statusResponse.data.result.selected_count || 0;
- tacticalToast.success(
- 'Round Generated',
- `Created round "${roundName}" with ${selectedCount.toLocaleString()} ${samplingTarget} selected`
- );
- // Trigger parent refresh
- onRoundCreated();
- } else if (statusResponse.data.status === 'failed') {
- clearInterval(pollInterval);
- tacticalToast.error(
- 'Round Generation Failed',
- statusResponse.data.error || 'Round generation failed'
- );
- }
- } catch (err) {
- console.error('Failed to check workflow status:', err);
+ onClose();
+ onRoundCreated();
+
+ if (Object.keys(workflowIds).length > 0) {
+ tacticalToast.info(
+ 'Sampling Started',
+ `Created round "${roundName}" and started sampling for ${Object.keys(workflowIds).length} area(s)`
+ );
+ if (onSamplingStarted) {
+ onSamplingStarted(workflowIds);
}
- }, 2000);
-
- // Clean up interval after 10 minutes (failsafe)
- setTimeout(() => clearInterval(pollInterval), 600000);
+ } else {
+ tacticalToast.success(
+ 'Round Created',
+ `Created round "${roundName}"`
+ );
+ }
}
} catch (err: any) {
console.error('Error creating round:', err);
@@ -204,6 +178,18 @@ const CreateRoundModal: React.FC = ({
}
};
+ const areaDisplayName = (area: CampaignArea) => {
+ const parts = [
+ area.name || area.admin_boundary_name || 'Unnamed Area',
+ ];
+ const hierarchy = [area.division_name, area.district_name, area.upazila_name, area.union_name]
+ .filter(Boolean);
+ if (hierarchy.length > 0) {
+ parts.push(`(${hierarchy.join(' > ')})`);
+ }
+ return parts.join(' ');
+ };
+
return (
= ({
>