diff --git a/backend/api/projects/statistics.py b/backend/api/projects/statistics.py
index 547bfa9cdb..cf048a4f4f 100644
--- a/backend/api/projects/statistics.py
+++ b/backend/api/projects/statistics.py
@@ -1,6 +1,7 @@
from databases import Database
from fastapi import APIRouter, Depends
-
+from fastapi.responses import JSONResponse
+import json
from backend.db import get_db
from backend.services.project_service import ProjectService
from backend.services.stats_service import StatsService
@@ -99,3 +100,57 @@ async def get_project_user_stats(
"""
stats_dto = await ProjectService.get_project_user_stats(project_id, username, db)
return stats_dto
+
+
+@router.get("/{project_id}/tasks/invalidated/")
+async def get_project_invalidated_counts(
+ project_id: int, db: Database = Depends(get_db)
+):
+ """
+ Return all tasks in a project with the number of times each task was invalidated.
+ Only counts task_history rows where action='STATE_CHANGE' and action_text='INVALIDATED'.
+ """
+ query = """
+ SELECT COALESCE(
+ jsonb_agg(
+ jsonb_build_object(
+ 'taskId', t.id,
+ 'invalidatedCount', COALESCE(th.cnt, 0)
+ ) ORDER BY t.id
+ ),
+ '[]'::jsonb
+ ) AS tasks
+ FROM tasks t
+ LEFT JOIN (
+ SELECT task_id, COUNT(*)::int AS cnt
+ FROM task_history
+ WHERE project_id = :project_id
+ AND action = 'STATE_CHANGE'
+ AND action_text = 'INVALIDATED'
+ GROUP BY task_id
+ ) th ON th.task_id = t.id
+ WHERE t.project_id = :project_id;
+ """
+
+ try:
+ row = await db.fetch_one(query=query, values={"project_id": project_id})
+ except Exception as e:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "Error": "Failed to query invalidation counts",
+ "SubCode": "InternalError",
+ "details": str(e),
+ },
+ )
+
+ tasks_raw = row["tasks"] if row else []
+ if isinstance(tasks_raw, str):
+ try:
+ tasks = json.loads(tasks_raw)
+ except Exception:
+ tasks = []
+ else:
+ tasks = tasks_raw or []
+
+ return JSONResponse(status_code=200, content={"tasks": tasks})
diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js
index 3fcb00c3cf..e175f87a0e 100644
--- a/frontend/src/api/projects.js
+++ b/frontend/src/api/projects.js
@@ -138,6 +138,22 @@ export const useTasksQuery = (projectId, otherOptions = {}) => {
});
};
+export const useInvalidatedTasksQuery = (projectId, otherOptions = {}) => {
+ const token = useSelector((state) => state.auth.token);
+ const fetchInvalidatedTasks = ({ signal }) => {
+ return api(token).get(`projects/${projectId}/tasks/invalidated/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-invalidated-tasks', projectId],
+ queryFn: fetchInvalidatedTasks,
+ select: (data) => data.data.tasks,
+ ...otherOptions,
+ });
+};
+
export const usePriorityAreasQuery = (projectId) => {
const fetchProjectPriorityArea = (signal) => {
return api().get(`projects/${projectId}/queries/priority-areas/`, {
diff --git a/frontend/src/components/taskSelection/index.js b/frontend/src/components/taskSelection/index.js
index d5ddf8f98f..78f4be75a0 100644
--- a/frontend/src/components/taskSelection/index.js
+++ b/frontend/src/components/taskSelection/index.js
@@ -30,6 +30,7 @@ import {
useActivitiesQuery,
useProjectContributionsQuery,
useTasksQuery,
+ useInvalidatedTasksQuery,
} from '../../api/projects';
import { useTeamsQuery } from '../../api/teams';
@@ -70,6 +71,7 @@ export function TaskSelection({ project }: Object) {
const [activeStatus, setActiveStatus] = useState(null);
const [activeUser, setActiveUser] = useState(null);
const [textSearch, setTextSearch] = useQueryParam('search', StringParam);
+ const [showChoropleth, setShowChoropleth] = useState(false);
const isFirstRender = useRef(true); // to check if component is rendered first time
const { data: userTeams, isLoading: isUserTeamsLoading } = useTeamsQuery(
@@ -101,6 +103,14 @@ export function TaskSelection({ project }: Object) {
isLoadingError: isPriorityAreasLoadingError,
} = usePriorityAreasQuery(projectId);
+ const { data: invalidatedTasksData } = useInvalidatedTasksQuery(projectId, {
+ enabled: showChoropleth,
+ // No staleTime — data is immediately stale so React Query refetches on
+ // every window focus or remount. This ensures if a user invalidates a task
+ // and comes back to this page, the count is always up to date.
+ refetchOnWindowFocus: true,
+ });
+
const tasks = tasksData && activities && updateTasksStatus(tasksData, activities);
const randomTask = activities && [getRandomTaskByAction(activities.activity, taskAction)];
const isValidationAllowed = user && userTeams && userCanValidate(user, project, userTeams.teams);
@@ -367,8 +377,11 @@ export function TaskSelection({ project }: Object) {
taskBordersOnly={false}
priorityAreas={priorityAreas}
animateZoom={false}
+ showChoropleth={showChoropleth}
+ invalidatedTasksData={invalidatedTasksData}
+ onToggleChoropleth={() => setShowChoropleth((v) => !v)}
/>
-
+
)}
diff --git a/frontend/src/components/taskSelection/legend.js b/frontend/src/components/taskSelection/legend.js
index 637a9394c1..8014b391c4 100644
--- a/frontend/src/components/taskSelection/legend.js
+++ b/frontend/src/components/taskSelection/legend.js
@@ -5,44 +5,96 @@ import messages from './messages';
import { TaskStatus } from './taskList';
import { LockIcon, ChevronDownIcon, ChevronUpIcon } from '../svgIcons';
-export function TasksMapLegend() {
+// Colour stops that exactly match the MapLibre interpolate expression in map.js
+const RAMP = [
+ { value: 0, color: '#f9fafb', label: '0' },
+ { value: 1, color: '#fde68a', label: '1' },
+ { value: 3, color: '#f97316', label: '3' },
+ { value: 6, color: '#b91c1c', label: '6+' },
+];
+
+function ChoroplethGradientLegend() {
+ const gradient = RAMP.map((s) => s.color).join(', ');
+ return (
+
+ {/* Continuous gradient bar — mirrors the interpolate ramp on the map */}
+
+ {/* Tick labels at each colour stop */}
+
+ {RAMP.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+ {/*
+
+
*/}
+
+ );
+}
+
+export function TasksMapLegend({ showChoropleth = false }) {
const lineClasses = 'mv2 blue-dark f5';
const [expand, setExpand] = useState(true);
+
return (
-
-
setExpand(!expand)}
- >
-
- {expand ? : }
-
+
+ {/* ── Header row: title + collapse chevron ── */}
+
+
setExpand(!expand)}
+ >
+ {showChoropleth ? (
+
+ ) : (
+
+ )}
+ {expand ? : }
+
+
+
+ {/* ── Legend items ── */}
{expand && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {showChoropleth ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
)}
diff --git a/frontend/src/components/taskSelection/map.js b/frontend/src/components/taskSelection/map.js
index 429089b0b3..109fa475dc 100644
--- a/frontend/src/components/taskSelection/map.js
+++ b/frontend/src/components/taskSelection/map.js
@@ -5,6 +5,8 @@ import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { FormattedMessage, useIntl } from 'react-intl';
+import { NineCellsGridIcon } from '../svgIcons';
+
import WebglUnsupported from '../webglUnsupported';
import isWebglSupported from '../../utils/isWebglSupported';
import useSetRTLTextPlugin from '../../utils/useSetRTLTextPlugin';
@@ -33,6 +35,9 @@ export const TasksMap = ({
animateZoom = true,
showTaskIds = false,
selected: selectedOnMap,
+ invalidatedTasksData,
+ showChoropleth = false,
+ onToggleChoropleth,
}) => {
const intl = useIntl();
const mapRef = createRef();
@@ -489,6 +494,120 @@ export const TasksMap = ({
intl,
]);
+ /* ------------------------------------------------------------------
+ * Choropleth effect: add / update / remove the invalidation-count layer
+ * ------------------------------------------------------------------ */
+ useLayoutEffect(() => {
+ if (!map) return;
+
+ const CHOROPLETH_SOURCE = 'tasks-invalidation-choropleth';
+ const CHOROPLETH_LAYER = 'tasks-invalidation-fill';
+
+ const buildChoroplethGeoJSON = () => {
+ if (!mapResults || !invalidatedTasksData) return null;
+ const countMap = {};
+ invalidatedTasksData.forEach(({ taskId, invalidatedCount }) => {
+ countMap[taskId] = invalidatedCount;
+ });
+ return {
+ type: 'FeatureCollection',
+ features: mapResults.features.map((f) => ({
+ ...f,
+ properties: {
+ ...f.properties,
+ invalidatedCount: countMap[f.properties.taskId] || 0,
+ },
+ })),
+ };
+ };
+
+ if (!showChoropleth) {
+ // Restore the normal task-status fill opacity
+ if (map.getLayer('tasks-fill')) {
+ map.setPaintProperty('tasks-fill', 'fill-opacity', 0.8);
+ }
+ // Hide the choropleth overlay
+ if (map.getLayer(CHOROPLETH_LAYER)) {
+ map.setLayoutProperty(CHOROPLETH_LAYER, 'visibility', 'none');
+ }
+ return;
+ }
+
+ if (!invalidatedTasksData || !mapResults?.features?.length) return;
+
+ const geojson = buildChoroplethGeoJSON();
+ if (!geojson) return;
+
+ const addOrUpdateLayer = () => {
+ try {
+ // Make tasks-fill invisible (opacity=0) but keep it in the layer stack
+ // so MapLibre click hit-testing still works on it.
+ if (map.getLayer('tasks-fill')) {
+ map.setPaintProperty('tasks-fill', 'fill-opacity', 0);
+ }
+
+ if (map.getSource(CHOROPLETH_SOURCE)) {
+ map.getSource(CHOROPLETH_SOURCE).setData(geojson);
+ if (map.getLayer(CHOROPLETH_LAYER)) {
+ map.setLayoutProperty(CHOROPLETH_LAYER, 'visibility', 'visible');
+ }
+ return;
+ }
+
+ map.addSource(CHOROPLETH_SOURCE, { type: 'geojson', data: geojson });
+
+ const layerDef = {
+ id: CHOROPLETH_LAYER,
+ type: 'fill',
+ source: CHOROPLETH_SOURCE,
+ paint: {
+ 'fill-color': [
+ 'interpolate', ['linear'], ['get', 'invalidatedCount'],
+ 0, '#f9fafb', // never invalidated: near-white
+ 1, '#fde68a', // once: light amber
+ 3, '#f97316', // moderate: orange
+ 6, '#b91c1c', // high: deep red
+ ],
+ 'fill-opacity': 1,
+ },
+ layout: { visibility: 'visible' },
+ };
+
+ // Insert BEFORE the border layers so grid lines always render on top.
+ // Fall back to appending only if no border layers exist yet.
+ const beforeLayer = map.getLayer('unselected-tasks-border')
+ ? 'unselected-tasks-border'
+ : map.getLayer('selected-tasks-border')
+ ? 'selected-tasks-border'
+ : undefined;
+
+ if (beforeLayer) {
+ map.addLayer(layerDef, beforeLayer);
+ } else {
+ map.addLayer(layerDef);
+ }
+ } catch (err) {
+ console.error('[Choropleth] Failed to add/update invalidation layer:', err);
+ }
+ };
+
+ // Attempt to add the layer immediately if the map is fully ready.
+ // If not (e.g. style still loading or tasks source not yet registered),
+ // defer to the next 'idle' event, which fires once all sources/tiles settle.
+ if (map.isStyleLoaded() && map.getSource('tasks') !== undefined) {
+ addOrUpdateLayer();
+ } else {
+ // 'load' only fires once and may already have fired; use 'idle' as
+ // a reliable one-shot that fires after all pending operations settle.
+ const onIdle = () => {
+ addOrUpdateLayer();
+ map.off('idle', onIdle);
+ };
+ map.on('idle', onIdle);
+ return () => map.off('idle', onIdle);
+ }
+ }, [map, showChoropleth, invalidatedTasksData, mapResults]);
+
if (!isWebglSupported()) {
return
;
} else {
@@ -499,6 +618,44 @@ export const TasksMap = ({
)}
+
+ {/* Choropleth toggle — icon-only square, grouped below zoom controls */}
+ {onToggleChoropleth && (
+
+ )}
+
>
);
diff --git a/frontend/src/components/taskSelection/messages.js b/frontend/src/components/taskSelection/messages.js
index 5582d59420..e38fa23ee1 100644
--- a/frontend/src/components/taskSelection/messages.js
+++ b/frontend/src/components/taskSelection/messages.js
@@ -866,4 +866,36 @@ export default defineMessages({
id: 'project.detail.sandbox.tooltip',
defaultMessage: 'This is a training project. Edits will not be saved to OpenStreetMap.',
},
+ invalidationChoropleth: {
+ id: 'project.tasks.map.invalidationChoropleth',
+ defaultMessage: 'Legend',
+ },
+ invalidationChoroplethToggle: {
+ id: 'project.tasks.map.invalidationChoropleth.toggle',
+ defaultMessage: 'Show invalidation heatmap',
+ },
+ invalidationChoroplethLegendTitle: {
+ id: 'project.tasks.map.invalidationChoropleth.legend.title',
+ defaultMessage: 'Invalidation count',
+ },
+ invalidationChoroplethNone: {
+ id: 'project.tasks.map.invalidationChoropleth.legend.none',
+ defaultMessage: 'Never invalidated',
+ },
+ invalidationChoroplethLow: {
+ id: 'project.tasks.map.invalidationChoropleth.legend.low',
+ defaultMessage: '1–2 times',
+ },
+ invalidationChoroplethMedium: {
+ id: 'project.tasks.map.invalidationChoropleth.legend.medium',
+ defaultMessage: '3–5 times',
+ },
+ invalidationChoroplethHigh: {
+ id: 'project.tasks.map.invalidationChoropleth.legend.high',
+ defaultMessage: '6+ times',
+ },
+ invalidationChoroplethRampHint: {
+ id: 'project.tasks.map.invalidationChoropleth.legend.rampHint',
+ defaultMessage: 'Colour blends continuously between stops',
+ },
});
diff --git a/frontend/src/components/taskSelection/tests/legend.test.js b/frontend/src/components/taskSelection/tests/legend.test.js
index 033b66656a..87a0a9b4e2 100644
--- a/frontend/src/components/taskSelection/tests/legend.test.js
+++ b/frontend/src/components/taskSelection/tests/legend.test.js
@@ -13,7 +13,8 @@ test('Legend collapse / expand when clicking', async () => {
,
);
- expect(screen.getByText('Legend').className).toBe('fw6 pointer f4 ttu barlow-condensed mt0 mb2');
+ // Legend title is present and all items are visible initially
+ expect(screen.getByText('Legend')).toBeInTheDocument();
expect(screen.getByText('Available for mapping')).toBeInTheDocument();
expect(screen.getByText('Ready for validation')).toBeInTheDocument();
expect(screen.getByText('Unavailable')).toBeInTheDocument();
@@ -21,8 +22,12 @@ test('Legend collapse / expand when clicking', async () => {
expect(screen.getByText('More mapping needed')).toBeInTheDocument();
expect(screen.getByText('Finished')).toBeInTheDocument();
expect(screen.getByText('Locked')).toBeInTheDocument();
+
+ // Clicking the legend title collapses the items
await user.click(screen.getByText('Legend'));
expect(screen.queryByText('Available for mapping')).not.toBeInTheDocument();
+
+ // Clicking again expands the items
await user.click(screen.getByText('Legend'));
expect(screen.getByText('Available for mapping')).toBeInTheDocument();
});