Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion backend/api/projects/statistics.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -99,3 +100,57 @@
"""
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)

Check warning on line 107 in backend/api/projects/statistics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=hotosm_tasking-manager&issues=AZ5ihavScewz58IZdz8c&open=AZ5ihavScewz58IZdz8c&pullRequest=7112
):
"""
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})
16 changes: 16 additions & 0 deletions frontend/src/api/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/`, {
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/components/taskSelection/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
useActivitiesQuery,
useProjectContributionsQuery,
useTasksQuery,
useInvalidatedTasksQuery,
} from '../../api/projects';
import { useTeamsQuery } from '../../api/teams';

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -367,8 +377,11 @@ export function TaskSelection({ project }: Object) {
taskBordersOnly={false}
priorityAreas={priorityAreas}
animateZoom={false}
showChoropleth={showChoropleth}
invalidatedTasksData={invalidatedTasksData}
onToggleChoropleth={() => setShowChoropleth((v) => !v)}
/>
<TasksMapLegend />
<TasksMapLegend showChoropleth={showChoropleth} />
</ReactPlaceholder>
)}
</div>
Expand Down
120 changes: 86 additions & 34 deletions frontend/src/components/taskSelection/legend.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,96 @@
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 (
<div className="mt2">
{/* Continuous gradient bar β€” mirrors the interpolate ramp on the map */}
<div
style={{
height: 14,
borderRadius: 3,
background: `linear-gradient(to right, ${gradient})`,
border: '1px solid #d1d5db',
}}
/>
{/* Tick labels at each colour stop */}
<div className="flex justify-between mt1">
{RAMP.map(({ value, label }) => (
<span key={value} className="f7 blue-dark" style={{ lineHeight: 1 }}>
{label}
</span>
))}
</div>
{/* <p className="f7 blue-dark mv1 i">
<FormattedMessage {...messages.invalidationChoroplethRampHint} />
</p> */}
</div>
);
}

export function TasksMapLegend({ showChoropleth = false }) {

Check warning on line 44 in frontend/src/components/taskSelection/legend.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'showChoropleth' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=hotosm_tasking-manager&issues=AZ5ihavAcewz58IZdz8b&open=AZ5ihavAcewz58IZdz8b&pullRequest=7112
const lineClasses = 'mv2 blue-dark f5';
const [expand, setExpand] = useState(true);

return (
<div className="cf left-1 bottom-2 absolute bg-white pa2 br1">
<h4
className="fw6 pointer f4 ttu barlow-condensed mt0 mb2"
onClick={() => setExpand(!expand)}
>
<FormattedMessage {...messages.legend} />
{expand ? <ChevronDownIcon className="pl2" /> : <ChevronUpIcon className="pl2" />}
</h4>
<div className="cf left-1 bottom-2 absolute bg-white pa2 br1" style={{ minWidth: 180 }}>
{/* ── Header row: title + collapse chevron ── */}
<div className="flex items-center justify-between">
<h4
className="fw6 pointer f4 ttu barlow-condensed mt0 mb0 flex-auto"
onClick={() => setExpand(!expand)}
>
{showChoropleth ? (
<FormattedMessage {...messages.invalidationChoropleth} />
) : (
<FormattedMessage {...messages.legend} />
)}
{expand ? <ChevronDownIcon className="pl2" /> : <ChevronUpIcon className="pl2" />}
</h4>
</div>

{/* ── Legend items ── */}
{expand && (
<div>
<p className={lineClasses}>
<TaskStatus status="READY" />
</p>
<p className={lineClasses}>
<TaskStatus status="MAPPED" />
</p>
<p className={lineClasses}>
<TaskStatus status="INVALIDATED" />
</p>
<p className={lineClasses}>
<TaskStatus status="VALIDATED" />
</p>
<p className={lineClasses}>
<TaskStatus status="BADIMAGERY" />
</p>
<p className={lineClasses}>
<TaskStatus status="PRIORITY_AREAS" />
</p>
<p className={lineClasses}>
<LockIcon style={{ paddingTop: '1px' }} className="v-mid h1 w1" />
<span className="pl2 v-mid">
<FormattedMessage {...messages[`taskStatus_LOCKED`]} />
</span>
</p>
<div className="mt1">
{showChoropleth ? (
<ChoroplethGradientLegend />
) : (
<>
<p className={lineClasses}>
<TaskStatus status="READY" />
</p>
<p className={lineClasses}>
<TaskStatus status="MAPPED" />
</p>
<p className={lineClasses}>
<TaskStatus status="INVALIDATED" />
</p>
<p className={lineClasses}>
<TaskStatus status="VALIDATED" />
</p>
<p className={lineClasses}>
<TaskStatus status="BADIMAGERY" />
</p>
<p className={lineClasses}>
<TaskStatus status="PRIORITY_AREAS" />
</p>
<p className={lineClasses}>
<LockIcon style={{ paddingTop: '1px' }} className="v-mid h1 w1" />
<span className="pl2 v-mid">
<FormattedMessage {...messages[`taskStatus_LOCKED`]} />
</span>
</p>
</>
)}
</div>
)}
</div>
Expand Down
Loading
Loading