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(); });