diff --git a/backend/__init__.py b/backend/__init__.py index c72913f422..4e032534ae 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -248,6 +248,12 @@ def add_api_endpoints(app): PartnersByProjectAPI, ) + # Partner statistics API + from backend.api.partners.statistics import ( + GroupPartnerStatisticsAPI, + FilteredPartnerStatisticsAPI, + ) + # Tasks API import from backend.api.tasks.resources import ( TasksRestAPI, @@ -590,6 +596,16 @@ def add_api_endpoints(app): format_url("partners//"), methods=["GET", "DELETE", "PUT"], ) + api.add_resource( + GroupPartnerStatisticsAPI, + format_url("/partners//general-statistics"), + methods=["GET"], + ) + api.add_resource( + FilteredPartnerStatisticsAPI, + format_url("/partners//filtered-statistics"), + methods=["GET"], + ) api.add_resource( PartnerPermalinkRestAPI, format_url("partners//"), diff --git a/backend/api/partners/statistics.py b/backend/api/partners/statistics.py new file mode 100644 index 0000000000..6d661fec99 --- /dev/null +++ b/backend/api/partners/statistics.py @@ -0,0 +1,174 @@ +import io +from flask import send_file +from flask_restful import Resource, request +from typing import Optional + + +from backend.services.partner_service import PartnerService +from backend.exceptions import BadRequest + +# Replaceable by another service which implements the method: +# fetch_partner_stats(id_inside_service, from_date, to_date) -> PartnerStatsDTO +from backend.services.mapswipe_service import MapswipeService + +MAPSWIPE_GROUP_EMPTY_SUBCODE = "EMPTY_MAPSWIPE_GROUP" +MAPSWIPE_GROUP_EMPTY_MESSAGE = "Mapswipe group is not set for this partner." + + +def is_valid_group_id(group_id: Optional[str]) -> bool: + return group_id is not None and len(group_id) > 0 + + +class FilteredPartnerStatisticsAPI(Resource): + def get(self, permalink: str): + """ + Get partner statistics by id and time range + --- + tags: + - partners + produces: + - application/json + parameters: + - in: query + name: fromDate + type: string + description: Fetch partner statistics from date as yyyy-mm-dd + example: "2024-01-01" + - in: query + name: toDate + type: string + example: "2024-09-01" + description: Fetch partner statistics to date as yyyy-mm-dd + - name: partner_id + in: path + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + mapswipe = MapswipeService() + from_date = request.args.get("fromDate") + to_date = request.args.get("toDate") + + if from_date is None: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="fromDate is missing", + from_date=from_date, + to_date=to_date, + ) + + if to_date is None: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="toDate is missing", + from_date=from_date, + to_date=to_date, + ) + + if from_date > to_date: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="fromDate should be less than toDate", + from_date=from_date, + to_date=to_date, + ) + + partner = PartnerService.get_partner_by_permalink(permalink) + + if not is_valid_group_id(partner.mapswipe_group_id): + raise BadRequest( + sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, + message=MAPSWIPE_GROUP_EMPTY_MESSAGE, + ) + + return ( + mapswipe.fetch_filtered_partner_stats( + partner.id, partner.mapswipe_group_id, from_date, to_date + ).to_primitive(), + 200, + ) + + +class GroupPartnerStatisticsAPI(Resource): + def get(self, permalink: str): + """ + Get partner statistics by id and broken down by each contributor. + This API is paginated with limit and offset query parameters. + --- + tags: + - partners + produces: + - application/json + parameters: + - in: query + name: limit + description: The number of partner members to fetch + type: integer + example: 10 + - in: query + name: offset + description: The starting index from which to fetch partner members + type: integer + example: 0 + - in: query + name: downloadAsCSV + description: Download users in this group as CSV + type: boolean + example: false + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + + mapswipe = MapswipeService() + partner = PartnerService.get_partner_by_permalink(permalink) + + if not is_valid_group_id(partner.mapswipe_group_id): + raise BadRequest( + sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, + message=MAPSWIPE_GROUP_EMPTY_MESSAGE, + ) + + limit = int(request.args.get("limit", 10)) + offset = int(request.args.get("offset", 0)) + download_as_csv = bool(request.args.get("downloadAsCSV", "false") == "true") + + group_dto = mapswipe.fetch_grouped_partner_stats( + partner.id, + partner.mapswipe_group_id, + limit, + offset, + download_as_csv, + ) + + if download_as_csv: + return send_file( + io.BytesIO(group_dto.to_csv().encode()), + mimetype="text/csv", + as_attachment=True, + download_name="partner_members.csv", + ) + + return group_dto.to_primitive(), 200 diff --git a/backend/models/dtos/partner_stats_dto.py b/backend/models/dtos/partner_stats_dto.py new file mode 100644 index 0000000000..59b75430c2 --- /dev/null +++ b/backend/models/dtos/partner_stats_dto.py @@ -0,0 +1,145 @@ +import pandas as pd +from schematics import Model +from schematics.types import ( + StringType, + LongType, + IntType, + ListType, + ModelType, + FloatType, + BooleanType, +) + + +class UserGroupMemberDTO(Model): + id = StringType() + user_id = StringType(serialized_name="userId") + username = StringType() + is_active = BooleanType(serialized_name="isActive") + total_mapping_projects = IntType(serialized_name="totalMappingProjects") + total_contribution_time = IntType(serialized_name="totalcontributionTime") + total_contributions = IntType(serialized_name="totalcontributions") + + +class OrganizationContributionsDTO(Model): + organization_name = StringType(serialized_name="organizationName") + total_contributions = IntType(serialized_name="totalcontributions") + + +class UserContributionsDTO(Model): + total_mapping_projects = IntType(serialized_name="totalMappingProjects") + total_contribution_time = IntType(serialized_name="totalcontributionTime") + total_contributions = IntType(serialized_name="totalcontributions") + username = StringType() + user_id = StringType(serialized_name="userId") + + +class GeojsonDTO(Model): + type = StringType() + coordinates = ListType(FloatType) + + +class GeoContributionsDTO(Model): + geojson = ModelType(GeojsonDTO) + total_contributions = IntType(serialized_name="totalcontributions") + + +class ContributionsByDateDTO(Model): + task_date = StringType(serialized_name="taskDate") + total_contributions = IntType(serialized_name="totalcontributions") + + +class ContributionTimeByDateDTO(Model): + date = StringType(serialized_name="date") + total_contribution_time = IntType(serialized_name="totalcontributionTime") + + +class ContributionsByProjectTypeDTO(Model): + project_type = StringType(serialized_name="projectType") + project_type_display = StringType(serialized_name="projectTypeDisplay") + total_contributions = IntType(serialized_name="totalcontributions") + + +class AreaSwipedByProjectTypeDTO(Model): + total_area = FloatType(serialized_name="totalArea") + project_type = StringType(serialized_name="projectType") + project_type_display = StringType(serialized_name="projectTypeDisplay") + + +class GroupedPartnerStatsDTO(Model): + """General statistics of a partner and its members.""" + + id = LongType() + provider = StringType() + id_inside_provider = StringType(serialized_name="idInsideProvider") + name_inside_provider = StringType(serialized_name="nameInsideProvider") + description_inside_provider = StringType( + serialized_name="descriptionInsideProvider" + ) + members_count = IntType(serialized_name="membersCount") + members = ListType(ModelType(UserGroupMemberDTO)) + + # General stats of partner + total_contributors = IntType(serialized_name="totalContributors") + total_contributions = IntType(serialized_name="totalcontributions") + total_contribution_time = IntType(serialized_name="totalcontributionTime") + + # Recent contributions during the last 1 month + total_recent_contributors = IntType(serialized_name="totalRecentContributors") + total_recent_contributions = IntType(serialized_name="totalRecentcontributions") + total_recent_contribution_time = IntType( + serialized_name="totalRecentcontributionTime" + ) + + def to_csv(self): + df = pd.json_normalize(self.to_primitive()["members"]) + + df.drop( + columns=["id"], + inplace=True, + axis=1, + ) + df.rename( + columns={ + "totalcontributionTime": "totalSwipeTimeInSeconds", + "totalcontributions": "totalSwipes", + }, + inplace=True, + ) + + return df.to_csv(index=False) + + +class FilteredPartnerStatsDTO(Model): + """Statistics of a partner contributions filtered by time range.""" + + id = LongType() + provider = StringType() + id_inside_provider = StringType(serialized_name="idInsideProvider") + + from_date = StringType(serialized_name="fromDate") + to_date = StringType(serialized_name="toDate") + contributions_by_user = ListType( + ModelType(UserContributionsDTO), serialized_name="contributionsByUser" + ) + contributions_by_geo = ListType( + ModelType(GeoContributionsDTO), serialized_name="contributionsByGeo" + ) + area_swiped_by_project_type = ListType( + ModelType(AreaSwipedByProjectTypeDTO), serialized_name="areaSwipedByProjectType" + ) + + contributions_by_project_type = ListType( + ModelType(ContributionsByProjectTypeDTO), + serialized_name="contributionsByProjectType", + ) + contributions_by_date = ListType( + ModelType(ContributionsByDateDTO), serialized_name="contributionsByDate" + ) + contributions_by_organization_name = ListType( + ModelType(OrganizationContributionsDTO), + serialized_name="contributionsByorganizationName", + ) + contribution_time_by_date = ListType( + ModelType(ContributionTimeByDateDTO), serialized_name="contributionTimeByDate" + ) diff --git a/backend/services/mapswipe_service.py b/backend/services/mapswipe_service.py new file mode 100644 index 0000000000..b41552c3df --- /dev/null +++ b/backend/services/mapswipe_service.py @@ -0,0 +1,340 @@ +import json +from backend.exceptions import Conflict +from backend.models.dtos.partner_stats_dto import ( + GroupedPartnerStatsDTO, + FilteredPartnerStatsDTO, + UserGroupMemberDTO, + UserContributionsDTO, + GeojsonDTO, + GeoContributionsDTO, + AreaSwipedByProjectTypeDTO, + ContributionsByDateDTO, + ContributionTimeByDateDTO, + ContributionsByProjectTypeDTO, + OrganizationContributionsDTO, +) +from cachetools import TTLCache, cached +import requests + +grouped_partner_stats_cache = TTLCache(maxsize=128, ttl=60 * 60 * 24) +filtered_partner_stats_cache = TTLCache(maxsize=128, ttl=60 * 60 * 24) +MAPSWIPE_API_URL = "https://api.mapswipe.org/graphql/" + + +class MapswipeService: + @staticmethod + def __build_query_user_group_stats(group_id: str, limit: int, offset: int): + """A private method to build a graphQl query for fetching a user group's stats from Mapswipe.""" + + operation_name = "UserGroupStats" + query = """ + query UserGroupStats($pk: ID!, $limit: Int!, $offset: Int!) { + userGroup(pk: $pk) { + id + userGroupId + name + description + userMemberships(pagination: {limit: $limit, offset: $offset}) { + count + items { + id + userId + username + isActive + totalMappingProjects + totalSwipeTime + totalSwipes + __typename + } + __typename + } + __typename + } + userGroupStats(userGroupId: $pk) { + id + stats { + totalContributors + totalSwipes + totalSwipeTime + __typename + } + statsLatest { + totalContributors + totalSwipeTime + totalSwipes + __typename + } + __typename + } + } + """ + variables = {"limit": limit, "offset": offset, "pk": group_id} + return {"operationName": operation_name, "query": query, "variables": variables} + + def __build_query_filtered_user_group_stats( + self, group_id: str, from_date: str, to_date: str + ): + """A private method to build a graphQl query to fetch a mapswipe group's stats within a timerange.""" + + operation_name = "FilteredUserGroupStats" + query = """ + query FilteredUserGroupStats($pk: ID!, $fromDate: DateTime!, $toDate: DateTime!) { + userGroup(pk: $pk) { + id + } + userGroupStats(userGroupId: $pk) { + id + filteredStats(dateRange: {fromDate: $fromDate, toDate: $toDate}) { + userStats { + totalMappingProjects + totalSwipeTime + totalSwipes + username + userId + __typename + } + contributionByGeo { + geojson + totalContribution + __typename + } + areaSwipedByProjectType { + totalArea + projectTypeDisplay + projectType + __typename + } + swipeByDate { + taskDate + totalSwipes + __typename + } + swipeTimeByDate { + date + totalSwipeTime + __typename + } + swipeByProjectType { + projectType + projectTypeDisplay + totalSwipes + __typename + } + swipeByOrganizationName { + organizationName + totalSwipes + __typename + } + __typename + } + __typename + } + } + """ + variables = {"fromDate": from_date, "toDate": to_date, "pk": group_id} + return {"operationName": operation_name, "query": query, "variables": variables} + + def setup_group_dto( + self, partner_id: str, group_id: str, resp_body: str + ) -> GroupedPartnerStatsDTO: + group_stats = json.loads(resp_body)["data"] + group_dto = GroupedPartnerStatsDTO() + group_dto.id = partner_id + group_dto.provider = "mapswipe" + group_dto.id_inside_provider = group_id + + if group_stats["userGroup"] is None: + raise Conflict( + "INVALID_MAPSWIPE_GROUP_ID", + "The mapswipe group ID linked to this partner is invalid. Please contact an admin.", + ) + + group_dto.name_inside_provider = group_stats["userGroup"]["name"] + group_dto.description_inside_provider = group_stats["userGroup"]["description"] + + group_dto.members_count = group_stats["userGroup"]["userMemberships"]["count"] + group_dto.members = [] + for user_resp in group_stats["userGroup"]["userMemberships"]["items"]: + user = UserGroupMemberDTO() + user.id = user_resp["id"] + user.is_active = user_resp["isActive"] + user.user_id = user_resp["userId"] + user.username = user_resp["username"] + user.total_contributions = user_resp["totalSwipes"] + user.total_contribution_time = user_resp["totalSwipeTime"] + user.total_mapping_projects = user_resp["totalMappingProjects"] + group_dto.members.append(user) + + group_dto.total_contributors = group_stats["userGroupStats"]["stats"][ + "totalContributors" + ] + group_dto.total_contributions = group_stats["userGroupStats"]["stats"][ + "totalSwipes" + ] + group_dto.total_contribution_time = group_stats["userGroupStats"]["stats"][ + "totalSwipeTime" + ] + group_dto.total_recent_contributors = group_stats["userGroupStats"][ + "statsLatest" + ]["totalContributors"] + group_dto.total_recent_contributions = group_stats["userGroupStats"][ + "statsLatest" + ]["totalSwipes"] + group_dto.total_recent_contribution_time = group_stats["userGroupStats"][ + "statsLatest" + ]["totalSwipeTime"] + + return group_dto + + @staticmethod + def setup_filtered_dto( + partner_id: str, + group_id: str, + from_date: str, + to_date: str, + resp_body: str, + ): + filtered_stats_dto = FilteredPartnerStatsDTO() + filtered_stats_dto.id = partner_id + filtered_stats_dto.provider = "mapswipe" + filtered_stats_dto.id_inside_provider = group_id + filtered_stats_dto.from_date = from_date + filtered_stats_dto.to_date = to_date + + filtered_stats = json.loads(resp_body)["data"] + + if filtered_stats["userGroup"] is None: + raise Conflict( + "INVALID_MAPSWIPE_GROUP_ID", + "The mapswipe group ID linked to this partner is invalid. Please contact an admin.", + ) + + filtered_stats = filtered_stats["userGroupStats"]["filteredStats"] + + stats_by_user = [] + for user_stats in filtered_stats["userStats"]: + user_contributions = UserContributionsDTO() + user_contributions.user_id = user_stats["userId"] + user_contributions.username = user_stats["username"] + user_contributions.total_contributions = user_stats["totalSwipes"] + user_contributions.total_contribution_time = user_stats["totalSwipeTime"] + user_contributions.total_mapping_projects = user_stats[ + "totalMappingProjects" + ] + stats_by_user.append(user_contributions) + filtered_stats_dto.contributions_by_user = stats_by_user + + contributions_by_geo = [] + for geo_stats in filtered_stats["contributionByGeo"]: + geo_contributions = GeoContributionsDTO() + geo_contributions.total_contributions = geo_stats["totalContribution"] + geojson = GeojsonDTO() + geojson.type = geo_stats["geojson"]["type"] + geojson.coordinates = geo_stats["geojson"]["coordinates"] + geo_contributions.geojson = geojson + contributions_by_geo.append(geo_contributions) + filtered_stats_dto.contributions_by_geo = contributions_by_geo + + areas_swiped = [] + for area_swiped in filtered_stats["areaSwipedByProjectType"]: + area_swiped_by_project_type = AreaSwipedByProjectTypeDTO() + area_swiped_by_project_type.project_type = area_swiped["projectType"] + area_swiped_by_project_type.project_type_display = area_swiped[ + "projectTypeDisplay" + ] + area_swiped_by_project_type.total_area = area_swiped["totalArea"] + areas_swiped.append(area_swiped_by_project_type) + filtered_stats_dto.area_swiped_by_project_type = areas_swiped + + dates = [] + for by_date in filtered_stats["swipeByDate"]: + contributions_by_date = ContributionsByDateDTO() + contributions_by_date.task_date = by_date["taskDate"] + contributions_by_date.total_contributions = by_date["totalSwipes"] + dates.append(contributions_by_date) + filtered_stats_dto.contributions_by_date = dates + + dates = [] + for by_date in filtered_stats["swipeTimeByDate"]: + contribution_time_by_date = ContributionTimeByDateDTO() + contribution_time_by_date.date = by_date["date"] + contribution_time_by_date.total_contribution_time = by_date[ + "totalSwipeTime" + ] + dates.append(contribution_time_by_date) + filtered_stats_dto.contribution_time_by_date = dates + + project_types = [] + for project_type in filtered_stats["swipeByProjectType"]: + contributions_by_project_type = ContributionsByProjectTypeDTO() + contributions_by_project_type.project_type = project_type["projectType"] + contributions_by_project_type.project_type_display = project_type[ + "projectTypeDisplay" + ] + contributions_by_project_type.total_contributions = project_type[ + "totalSwipes" + ] + project_types.append(contributions_by_project_type) + filtered_stats_dto.contributions_by_project_type = project_types + + organizations = [] + for organization in filtered_stats["swipeByOrganizationName"]: + contributions_by_organization_name = OrganizationContributionsDTO() + contributions_by_organization_name.organization_name = organization[ + "organizationName" + ] + contributions_by_organization_name.total_contributions = organization[ + "totalSwipes" + ] + organizations.append(contributions_by_organization_name) + filtered_stats_dto.contributions_by_organization_name = organizations + return filtered_stats_dto + + @cached(grouped_partner_stats_cache) + def fetch_grouped_partner_stats( + self, + partner_id: int, + group_id: str, + limit: int, + offset: int, + download_as_csv: bool, + ) -> GroupedPartnerStatsDTO: + """Service to fetch user group statistics by each member with pagination""" + + if download_as_csv: + limit = 1_000_000 + offset = 0 + + resp_body = requests.post( + MAPSWIPE_API_URL, + headers={"Content-Type": "application/json"}, + data=json.dumps( + self.__build_query_user_group_stats(group_id, limit, offset) + ), + ).text + + group_dto = self.setup_group_dto(partner_id, group_id, resp_body) + return group_dto + + @cached(filtered_partner_stats_cache) + def fetch_filtered_partner_stats( + self, + partner_id: str, + group_id: str, + from_date: str, + to_date: str, + ) -> FilteredPartnerStatsDTO: + resp = requests.post( + MAPSWIPE_API_URL, + headers={"Content-Type": "application/json"}, + data=json.dumps( + self.__build_query_filtered_user_group_stats( + group_id, from_date, to_date + ) + ), + ) + + filtered_dto = self.setup_filtered_dto( + partner_id, group_id, from_date, to_date, resp.text + ) + return filtered_dto diff --git a/frontend/package.json b/frontend/package.json index 4834b88423..db755b6012 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "downshift-hooks": "^0.8.1", "final-form": "^4.20.10", "fromentries": "^1.3.2", + "h3-js": "^4.1.0", "humanize-duration": "^3.31.0", "mapbox-gl": "^1.13.3", "mapbox-gl-draw-rectangle-mode": "^1.0.4", diff --git a/frontend/src/components/partnerMapswipeStats/contributionsGrid.js b/frontend/src/components/partnerMapswipeStats/contributionsGrid.js new file mode 100644 index 0000000000..691231e83e --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/contributionsGrid.js @@ -0,0 +1,117 @@ +import CalendarHeatmap from 'react-calendar-heatmap'; +import { Tooltip } from 'react-tooltip'; +import { FormattedMessage, useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; + +import messages from './messages'; + +const LEGEND_INDEXES = [30, 50, 70, 100]; +const Legend = () => { + const legendFontStyle = 'ph2 f7 blue-grey ttc'; + + return ( +
+ + + +
+ {LEGEND_INDEXES.map((i) => ( +
+ ))} + + + +
+ ); +}; + +export const ContributionsGrid = ({ contributionsByDate = [] }) => { + const gridData = contributionsByDate.map((contribution) => ({ + date: contribution.taskDate, + count: contribution.totalcontributions, + })); + const intl = useIntl(); + + const getDate = (isEndDate = false) => { + const today = new Date(); + const currentYear = today.getFullYear(); + + const formatDate = (date) => { + const offset = date.getTimezoneOffset(); + return new Date(date.getTime() - offset * 60 * 1000); + }; + + return !isEndDate + ? formatDate(new Date(currentYear - 1, 11, 31)) + : formatDate(new Date(currentYear, 11, 31)); + }; + + const countValues = gridData.map((contribution) => contribution.count); + const maxValue = Math.max(...countValues); + + const getHeatmapClass = (value) => { + const rate = value.count / maxValue; + + if (0.0 <= rate && rate < 0.25) { + return 'fill-red o-30'; + } + + if (0.25 <= rate && rate < 0.5) { + return 'fill-red o-50'; + } + + if (0.5 <= rate && rate < 0.75) { + return 'fill-red o-70'; + } + + if (0.75 <= rate && rate <= 1) { + return 'fill-red o-100'; + } + }; + + return ( +
+

+ +

+ +
+ { + if (!value) return 'fill-tan'; + return getHeatmapClass(value); + }} + showWeekdayLabels={true} + tooltipDataAttrs={(value) => { + let tooltipContent = intl.formatMessage(messages.contributionsGridEmpty); + if (value.count !== null) { + tooltipContent = `${value.count} ${intl.formatMessage( + messages.contributionsGridTooltip, + )} on ${value.date}`; + } + + return { + 'data-tooltip-float': true, + 'data-tooltip-content': tooltipContent, + 'data-tooltip-id': 'partnerMapswipeContributionsGridTooltip', + }; + }} + /> + + +
+
+ ); +}; + +ContributionsGrid.propTypes = { + contributionsByDate: PropTypes.arrayOf( + PropTypes.shape({ + taskDate: PropTypes.string, + totalcontributions: PropTypes.number, + }), + ), +}; diff --git a/frontend/src/components/partnerMapswipeStats/contributionsHeatmap.css b/frontend/src/components/partnerMapswipeStats/contributionsHeatmap.css new file mode 100644 index 0000000000..2b7ce2b649 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/contributionsHeatmap.css @@ -0,0 +1,7 @@ +.partner-mapswipe-heatmap-zoom-text { + visibility: hidden; + user-select: none; +} +.partner-mapswipe-heatmap-wrapper:hover .partner-mapswipe-heatmap-zoom-text { + visibility: visible; +} diff --git a/frontend/src/components/partnerMapswipeStats/contributionsHeatmap.js b/frontend/src/components/partnerMapswipeStats/contributionsHeatmap.js new file mode 100644 index 0000000000..9b82c35b55 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/contributionsHeatmap.js @@ -0,0 +1,213 @@ +import React, { useEffect, useRef } from 'react'; +import mapboxgl from 'mapbox-gl'; +import { latLngToCell, cellToBoundary } from 'h3-js'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import 'mapbox-gl/dist/mapbox-gl.css'; + +import { MAPBOX_TOKEN, MAP_STYLE, CHART_COLOURS } from '../../config'; +import messages from './messages'; +import './contributionsHeatmap.css'; + +mapboxgl.accessToken = MAPBOX_TOKEN; + +export const ContributionsHeatmap = ({ contributionsByGeo = [] }) => { + const mapContainer = useRef(null); + const map = useRef(null); + + useEffect(() => { + if (map.current) return; // initialize map only once + + map.current = new mapboxgl.Map({ + container: mapContainer.current, + style: MAP_STYLE, + center: [0, 0], + zoom: 1.25, + }); + + map.current.scrollZoom.disable(); + map.current.addControl(new mapboxgl.NavigationControl()); + + const getStyle = (row) => { + const styles = [ + { + color: CHART_COLOURS.red, + opacity: 0.2, + }, + { + color: CHART_COLOURS.red, + opacity: 0.3, + }, + { + color: CHART_COLOURS.red, + opacity: 0.4, + }, + { + color: CHART_COLOURS.red, + opacity: 0.6, + }, + { + color: CHART_COLOURS.red, + opacity: 0.8, + }, + ]; + + if (Number(row.totalContributionCount) === 0) { + return { opacity: 0 }; + } + + if (Number(row.totalContributionCount) < 250) { + return styles[0]; + } + if (Number(row.totalContributionCount) < 500) { + return styles[1]; + } + if (Number(row.totalContributionCount) < 1000) { + return styles[2]; + } + if (Number(row.totalContributionCount) < 1500) { + return styles[3]; + } + return styles[4]; + }; + + const getHexagonFeatures = (res = 1) => { + const hexagonsArray = contributionsByGeo.map((data) => { + const h3Index = latLngToCell(data.geojson.coordinates[1], data.geojson.coordinates[0], res); + return { + hexindex7: h3Index, + totalContributionCount: data.totalContribution, + }; + }); + + const features = hexagonsArray.map((row) => { + const style = getStyle(row); + return { + type: 'Feature', + properties: { + color: style.color, + opacity: style.opacity, + id: row.hexindex7, + }, + geometry: { + type: 'Polygon', + coordinates: [cellToBoundary(row.hexindex7, true)], + }, + }; + }); + + return features; + }; + + const zoomToH3ResMapping = { + 1: 1, + 2: 2, + 3: 2, + 4: 3, + 5: 3, + 6: 4, + 7: 4, + 8: 5, + 9: 6, + 10: 6, + 11: 7, + 12: 7, + 13: 8, + 14: 8, + 15: 9, + 16: 10, + 17: 10, + 18: 11, + 19: 12, + 20: 13, + 21: 13, + 22: 14, + 23: 15, + 24: 15, + }; + + map.current.on('load', () => { + map.current.addSource('hexbin', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: getHexagonFeatures(), + }, + }); + + map.current.addLayer({ + id: 'polyline-layer', + type: 'fill', + source: 'hexbin', + paint: { + 'fill-outline-color': 'white', + 'fill-color': ['get', 'color'], + 'fill-opacity': ['get', 'opacity'], + }, + }); + + map.current.addLayer({ + id: 'hexbin-outline', + type: 'line', + source: 'hexbin', + paint: { + 'line-color': '#ffffff', + 'line-width': 1, + }, + }); + }); + + map.current.on('wheel', (event) => { + if (event.originalEvent.ctrlKey) { + // Check if CTRL key is pressed + event.originalEvent.preventDefault(); // Prevent chrome/firefox default behavior + if (!map.current.scrollZoom._enabled) map.current.scrollZoom.enable(); // Enable zoom only if it's disabled + } else if (map.current.scrollZoom._enabled) { + map.current.scrollZoom.disable(); // Disable zoom only if it's enabled + } + }); + + map.current.on('zoomend', () => { + const currentZoom = map.current.getZoom(); + const h3ResBasedOnZoom = + currentZoom >= 1 + ? zoomToH3ResMapping[parseInt(currentZoom)] ?? Math.floor((currentZoom - 2) * 0.7) + : 1; + + map.current.getSource('hexbin').setData({ + type: 'FeatureCollection', + features: getHexagonFeatures(h3ResBasedOnZoom), + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+

+ +

+ +
+
+
+

+ Use Ctrl + Scroll to zoom +

+
+
+
+ ); +}; + +ContributionsHeatmap.propTypes = { + contributionsByGeo: PropTypes.arrayOf( + PropTypes.shape({ + totalcontributions: PropTypes.number, + geojson: PropTypes.shape({ + type: PropTypes.string, + coordinates: PropTypes.array, + }), + }), + ), +}; diff --git a/frontend/src/components/partnerMapswipeStats/groupMembers.css b/frontend/src/components/partnerMapswipeStats/groupMembers.css new file mode 100644 index 0000000000..b47096b6b8 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/groupMembers.css @@ -0,0 +1,14 @@ +.partner-mapswipe-stats-column-resizer { + position: absolute; + top: 27%; + height: 40%; + width: 2px; + background: rgba(177, 177, 177, 0.5); + cursor: col-resize; + user-select: none; + touch-action: none; +} + +.partner-mapswipe-stats-column-resizer.isResizing { + background: rgb(131, 131, 131); +} diff --git a/frontend/src/components/partnerMapswipeStats/groupMembers.js b/frontend/src/components/partnerMapswipeStats/groupMembers.js new file mode 100644 index 0000000000..5e3d47d129 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/groupMembers.js @@ -0,0 +1,215 @@ +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'; +import { useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import ReactPlaceholder from 'react-placeholder'; + +import { fetchLocalJSONAPI } from '../../network/genericJSONRequest'; +import { BanIcon, CircleExclamationIcon, DownloadIcon } from '../svgIcons'; +import { formatSecondsToTwoUnits } from './overview'; +import { PaginatorLine } from '../paginator'; +import { API_URL } from '../../config'; +import messages from './messages'; +import './groupMembers.css'; + +const COLUMNS = [ + { + accessorKey: 'username', + header: () => ( + + + + ), + cell: ({ row }) => {row.original.username}, + }, + { + accessorKey: 'totalcontributions', + header: () => ( + + + + ), + }, + { + accessorKey: 'totalMappingProjects', + header: () => ( + + + + ), + }, + { + accessorKey: 'totalcontributionTime', + header: () => ( + + + + ), + cell: ({ row }) => ( + + {row.original.totalcontributionTime + ? formatSecondsToTwoUnits(row.original.totalcontributionTime) + : '0'} + + ), + }, +]; + +const GroupMembersPlaceholder = () => ( + <> + {new Array(9).fill( + + {new Array(4).fill( + + + , + )} + , + )} + +); + +export const GroupMembers = () => { + const [pageNumber, setPageNumber] = useState(0); + const { id: partnerPermalink } = useParams(); + + const rows = 10; + + const { isLoading, isError, data, isFetching } = useQuery({ + queryKey: ['partners-mapswipe-statistics-group-members', partnerPermalink, pageNumber], + queryFn: async () => { + const response = await fetchLocalJSONAPI( + `partners/${partnerPermalink}/general-statistics/?limit=${rows}&offset=${ + pageNumber * rows + }`, + ); + return response; + }, + keepPreviousData: true, + staleTime: 5 * 60 * 1000, + }); + + const table = useReactTable({ + columns: COLUMNS, + data: data?.members ?? [], + getCoreRowModel: getCoreRowModel(), + columnResizeMode: 'onChange', + columnResizeDirection: 'ltr', + }); + + const isEmpty = !isLoading && !isFetching && !isError && data?.members.length === 0; + + return ( +
+
+

+ +

+ + + + +
+ +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + } + ready={!isLoading && !isFetching} + > + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + {header.id !== 'totalcontributionTime' && ( +
header.column.resetSize(), + onMouseDown: header.getResizeHandler(), + onTouchStart: header.getResizeHandler(), + className: `partner-mapswipe-stats-column-resizer ${ + table.options.columnResizeDirection === 'ltr' ? 'right-0' : '' + } ${header.column.getIsResizing() ? 'isResizing' : ''}`, + }} + /> + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ + {isError ? ( +
+ +

+ +

+
+ ) : null} + + {isEmpty && ( +
+ +

+ +

+
+ )} +
+ +
+ setPageNumber(newPageNumber - 1)} + lastPage={Math.max(Math.ceil((data?.membersCount ?? 0) / rows), 1)} + className="flex items-center justify-center pa4" + /> +
+
+
+ ); +}; diff --git a/frontend/src/components/partnerMapswipeStats/messages.js b/frontend/src/components/partnerMapswipeStats/messages.js new file mode 100644 index 0000000000..8f5a3d4a98 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/messages.js @@ -0,0 +1,135 @@ +import { defineMessages } from 'react-intl'; + +/** + * Internationalized messages for use on teams and orgs. + */ +export default defineMessages({ + totalSwipes: { + id: 'management.partners.stats.mapswipe.totalSwipes', + defaultMessage: 'Total Swipes', + }, + recentTotalSwipesText: { + id: 'management.partners.stats.mapswipe.totalSwipes.recentText', + defaultMessage: 'swipes in the last 30 days', + }, + totalTimeSpent: { + id: 'management.partners.stats.mapswipe.totalTimeSpent', + defaultMessage: 'Total Time Spent', + }, + recentTotalTimeSpentText: { + id: 'management.partners.stats.mapswipe.totalTimeSpent.recentText', + defaultMessage: 'in the last 30 days', + }, + totalContributors: { + id: 'management.partners.stats.mapswipe.totalContributors', + defaultMessage: 'Total Contributors', + }, + recentTotalContributorsText: { + id: 'management.partners.stats.mapswipe.totalContributors.recentText', + defaultMessage: 'active contributors in the last 30 days', + }, + groupMembers: { + id: 'management.partners.stats.mapswipe.groupMembers', + defaultMessage: 'Group Members', + }, + userColumn: { + id: 'management.partners.stats.mapswipe.groupMembers.user', + defaultMessage: 'User', + }, + totalSwipesColumn: { + id: 'management.partners.stats.mapswipe.groupMembers.totalSwipes', + defaultMessage: 'Total Swipes', + }, + projectContributedColumn: { + id: 'management.partners.stats.mapswipe.groupMembers.projectContributed', + defaultMessage: 'Project Contributed', + }, + timeSpentColumn: { + id: 'management.partners.stats.mapswipe.groupMembers.timeSpent', + defaultMessage: 'Time Spent', + }, + groupMembersTableEmpty: { + id: 'management.partners.stats.mapswipe.groupMembers.empty', + defaultMessage: 'No group members were found.', + }, + groupMembersTableError: { + id: 'management.partners.stats.mapswipe.groupMembers.error', + defaultMessage: 'Something went wrong!', + }, + contributions: { + id: 'management.partners.stats.mapswipe.contributions', + defaultMessage: 'Contributions', + }, + contributionsGridLegendLess: { + id: 'management.partners.stats.mapswipe.contributions.legendLess', + defaultMessage: 'Low', + }, + contributionsGridLegendMore: { + id: 'management.partners.stats.mapswipe.contributions.legendMore', + defaultMessage: 'High', + }, + contributionsGridEmpty: { + id: 'management.partners.stats.mapswipe.contributions.empty', + defaultMessage: 'No swipes', + }, + contributionsGridTooltip: { + id: 'management.partners.stats.mapswipe.contributions.tooltip', + defaultMessage: 'swipes', + }, + contributionsHeatmap: { + id: 'management.partners.stats.mapswipe.contributions.heatmap', + defaultMessage: 'Contributions Heatmap', + }, + timeSpentContributing: { + id: 'management.partners.stats.mapswipe.timeSpentContributing', + defaultMessage: 'Time Spent Contributing', + }, + timeSpentContributingDayOption: { + id: 'management.partners.stats.mapswipe.timeSpentContributing.options.day', + defaultMessage: 'Day', + }, + timeSpentContributingMonthOption: { + id: 'management.partners.stats.mapswipe.timeSpentContributing.options.month', + defaultMessage: 'Month', + }, + timeSpentContributingByDay: { + id: 'management.partners.stats.mapswipe.timeSpentContributingByDay', + defaultMessage: 'Time Spent Contributing by Day of Week', + }, + swipesByProjectType: { + id: 'management.partners.stats.mapswipe.swipesByProjectType', + defaultMessage: 'Swipes by Project Type', + }, + swipesByOrganization: { + id: 'management.partners.stats.mapswipe.swipesByOrganization', + defaultMessage: 'Swipes by Organization', + }, + areaSwipesByProjectType: { + id: 'management.partners.stats.mapswipe.areaSwipes', + defaultMessage: 'Area Swipes', + }, + find: { + id: 'management.partners.stats.mapswipe.area.find', + defaultMessage: 'Find', + }, + featuresCheckedByProjectType: { + id: 'management.partners.stats.mapswipe.featuresChecked', + defaultMessage: 'Features Checked', + }, + validate: { + id: 'management.partners.stats.mapswipe.area.validate', + defaultMessage: 'Validate', + }, + sceneComparedByProjectType: { + id: 'management.partners.stats.mapswipe.sceneCompared', + defaultMessage: 'Scene Compared', + }, + compare: { + id: 'management.partners.stats.mapswipe.area.compare', + defaultMessage: 'Compare', + }, + downloadMembersAsCSV: { + id: 'management.partners.stats.mapswipe.groupMembers.download', + defaultMessage: 'Download CSV', + }, +}); diff --git a/frontend/src/components/partnerMapswipeStats/overview.js b/frontend/src/components/partnerMapswipeStats/overview.js new file mode 100644 index 0000000000..0eeaf21818 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/overview.js @@ -0,0 +1,171 @@ +import { FormattedMessage, FormattedNumber } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import ReactPlaceholder from 'react-placeholder'; +import shortNumber from 'short-number'; +import { intervalToDuration } from 'date-fns'; + +import messages from './messages'; +import { PeopleIcon, SwipeIcon, ClockIcon } from '../svgIcons'; +import { fetchLocalJSONAPI } from '../../network/genericJSONRequest'; + +const iconClass = 'w-100 red'; +const iconStyle = { height: '70px' }; + +const OverviewPlaceholder = () => ( +
+ + + +
+); + +export const formatSecondsToTwoUnits = (seconds, shortFormat = false) => { + const duration = intervalToDuration({ start: 0, end: seconds * 1000 }); + const units = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds']; + const shortUnitsMapping = { + years: 'yrs', + months: 'mos', + weeks: 'wks', + days: 'ds', + hours: 'hrs', + minutes: 'mins', + seconds: 'secs', + }; + + // Filter units that have a value greater than 0 + const nonZeroUnits = units.filter((unit) => duration[unit] > 0).slice(0, 2); + + const getUnitString = (unit) => { + if (duration[unit] === 1) { + return shortFormat ? shortUnitsMapping[unit].slice(0, -1) : unit.slice(0, -1); + } else { + return shortFormat ? shortUnitsMapping[unit] : unit; + } + }; + + return nonZeroUnits.map((unit) => `${duration[unit]} ${getUnitString(unit)}`).join(' '); +}; + +export const getShortNumber = (value) => { + const shortNumberValue = shortNumber(value); + + return typeof shortNumberValue === 'number' ? ( + + ) : ( + + +   + {shortNumberValue.substr(-1)} + + ); +}; + +export const Overview = () => { + const { id: partnerPermalink } = useParams(); + + const { isLoading, data, isRefetching } = useQuery({ + queryKey: ['partners-mapswipe-general-statistics', partnerPermalink], + queryFn: async () => { + const response = await fetchLocalJSONAPI( + `partners/${partnerPermalink}/general-statistics/?limit=0`, + ); + return response; + }, + }); + + return ( + } + ready={!isLoading && !isRefetching} + > +
+
+
+ +
+
+

+ {data?.totalcontributions ? getShortNumber(data.totalcontributions) : '-'} +

+ + + + + {data?.totalRecentcontributions ? ( + + {getShortNumber(data.totalRecentcontributions)}{' '} + + + ) : ( + '-' + )} +
+
+ +
+
+ +
+
+

+ {data?.totalcontributionTime + ? formatSecondsToTwoUnits(data.totalcontributionTime) + : '-'} +

+ + + + + {data?.totalRecentcontributionTime ? ( + + {formatSecondsToTwoUnits(data.totalRecentcontributionTime, true)}{' '} + + + ) : ( + '--' + )} +
+
+ +
+
+ +
+
+

+ {data?.totalContributors ? getShortNumber(data.totalContributors) : '-'} +

+ + + + + {data?.totalRecentContributors ? ( + + {getShortNumber(data.totalRecentContributors)}{' '} + + + ) : ( + '-' + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/partnerMapswipeStats/projectTypeAreaStats.js b/frontend/src/components/partnerMapswipeStats/projectTypeAreaStats.js new file mode 100644 index 0000000000..71fd4b2f27 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/projectTypeAreaStats.js @@ -0,0 +1,125 @@ +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +import messages from './messages'; +import { ChecksGridIcon, ColumnsGapIcon, FullscreenIcon } from '../svgIcons'; +import { getShortNumber } from './overview'; + +const iconClass = 'w-100 red'; +const iconStyle = { height: '55px' }; + +export const ProjectTypeAreaStats = ({ + projectTypeAreaStats = [], + areaSwipedByProjectType = [], +}) => { + const data = { + find: { + totalcontributions: 0, + totalArea: 0, + }, + validate: { + totalcontributions: 0, + }, + compare: { + totalcontributions: 0, + totalArea: 0, + }, + }; + + projectTypeAreaStats.forEach((stat) => { + if (['build_area', 'buildarea'].includes(stat.projectType.toLowerCase())) { + data.find.totalcontributions = getShortNumber(stat.totalcontributions || 0); + } else if (['foot_print', 'footprint'].includes(stat.projectType.toLowerCase())) { + data.validate.totalcontributions = getShortNumber(stat.totalcontributions || 0); + } else if (['change_detection', 'changedetection'].includes(stat.projectType.toLowerCase())) { + data.compare.totalcontributions = getShortNumber(stat.totalcontributions || 0); + } + }); + + areaSwipedByProjectType.forEach((stat) => { + if (['build_area', 'buildarea'].includes(stat.projectType.toLowerCase())) { + data.find.totalArea = getShortNumber(stat.totalArea || 0); + } else if (['change_detection', 'changedetection'].includes(stat.projectType.toLowerCase())) { + data.compare.totalArea = getShortNumber(stat.totalArea || 0); + } + }); + + return ( +
+
+
+ +
+
+ + + +

{data.find.totalcontributions}

+ + + + + + {data.find.totalArea} km2 + +
+
+ +
+
+ +
+
+ + + +

{data.validate.totalcontributions}

+ + + +
+
+ +
+
+ +
+
+ + + +

{data.compare.totalcontributions}

+ + + + + + {data.compare.totalArea} km2 + +
+
+
+ ); +}; + +ProjectTypeAreaStats.propTypes = { + projectTypeAreaStats: PropTypes.arrayOf( + PropTypes.shape({ + projectType: PropTypes.string, + projectTypeDisplay: PropTypes.string, + totalcontributions: PropTypes.numberstring, + }), + ), + areaSwipedByProjectType: PropTypes.arrayOf( + PropTypes.shape({ + organizationName: PropTypes.string, + totalcontributions: PropTypes.number, + }), + ), +}; diff --git a/frontend/src/components/partnerMapswipeStats/swipesByOrganization.js b/frontend/src/components/partnerMapswipeStats/swipesByOrganization.js new file mode 100644 index 0000000000..9209740dc3 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/swipesByOrganization.js @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import Chart from 'chart.js/auto'; +import PropTypes from 'prop-types'; + +import { CHART_COLOURS } from '../../config'; +import { EmptySetIcon } from '../svgIcons'; +import messages from './messages'; + +function withGroupedLowContributors(contributionsByOrganization, keepTop = 4) { + if (contributionsByOrganization.length <= keepTop) { + return contributionsByOrganization; + } + + contributionsByOrganization.sort((a, b) => b.totalcontributions - a.totalcontributions); + const topContributors = contributionsByOrganization.slice(0, keepTop); + const others = contributionsByOrganization + .slice(keepTop) + .reduce( + (acc, c) => ({ ...acc, totalcontributions: acc.totalcontributions + c.totalcontributions }), + { organizationName: 'Others', totalcontributions: 0 }, + ); + topContributors.push(others); + return topContributors; +} +export const SwipesByOrganization = ({ contributionsByOrganization = [] }) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + contributionsByOrganization = withGroupedLowContributors(contributionsByOrganization); + + useEffect(() => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + + if (!chartRef.current) return; + + const context = chartRef.current.getContext('2d'); + + chartInstance.current = new Chart(context, { + type: 'doughnut', + data: { + labels: contributionsByOrganization.map((item) => item.organizationName), + datasets: [ + { + data: contributionsByOrganization.map((item) => item.totalcontributions), + backgroundColor: [ + CHART_COLOURS.red, + CHART_COLOURS.orange, + CHART_COLOURS.green, + CHART_COLOURS.blue, + CHART_COLOURS.gray, + ], + borderColor: '#fff', + borderWidth: 2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + boxWidth: 15, + padding: 15, + }, + }, + }, + }, + }); + + return () => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+

+ +

+ +
+ + {contributionsByOrganization.length === 0 && ( +
+ +

No data found

+
+ )} +
+
+ ); +}; + +SwipesByOrganization.propTypes = { + contributionsByOrganization: PropTypes.arrayOf( + PropTypes.shape({ + organizationName: PropTypes.string, + totalcontributions: PropTypes.numberstring, + }), + ), +}; diff --git a/frontend/src/components/partnerMapswipeStats/swipesByProjectType.js b/frontend/src/components/partnerMapswipeStats/swipesByProjectType.js new file mode 100644 index 0000000000..399c00f608 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/swipesByProjectType.js @@ -0,0 +1,111 @@ +import { useEffect, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import Chart from 'chart.js/auto'; +import PropTypes from 'prop-types'; + +import { CHART_COLOURS } from '../../config'; +import { EmptySetIcon } from '../svgIcons'; +import messages from './messages'; + +export const SwipesByProjectType = ({ contributionsByProjectType = [] }) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + useEffect(() => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + + if (!chartRef.current) return; + + const chartData = [], + labelsData = [], + backgroundColors = []; + + contributionsByProjectType.forEach((stat) => { + if (['build_area', 'buildarea'].includes(stat.projectType.toLowerCase())) { + const contributionsCount = stat.totalcontributions || 0; + if (contributionsCount > 0) { + chartData.push(contributionsCount); + labelsData.push('Find'); + backgroundColors.push(CHART_COLOURS.orange); + } + } else if (['foot_print', 'footprint'].includes(stat.projectType.toLowerCase())) { + const contributionsCount = stat.totalcontributions || 0; + if (contributionsCount > 0) { + chartData.push(contributionsCount); + labelsData.push('Validate'); + backgroundColors.push(CHART_COLOURS.green); + } + } else if (['change_detection', 'changedetection'].includes(stat.projectType.toLowerCase())) { + const contributionsCount = stat.totalcontributions || 0; + if (contributionsCount > 0) { + chartData.push(contributionsCount); + labelsData.push('Compare'); + backgroundColors.push(CHART_COLOURS.blue); + } + } + }); + + const context = chartRef.current.getContext('2d'); + + chartInstance.current = new Chart(context, { + type: 'doughnut', + data: { + labels: labelsData, + datasets: [ + { + data: chartData, + backgroundColor: backgroundColors, + borderColor: '#fff', + borderWidth: 2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + }, + }, + }, + }); + + return () => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+

+ +

+ +
+ + {contributionsByProjectType.length === 0 && ( +
+ +

No data found

+
+ )} +
+
+ ); +}; + +SwipesByProjectType.propTypes = { + contributionsByProjectType: PropTypes.arrayOf( + PropTypes.shape({ + projectType: PropTypes.string, + projectTypeDisplay: PropTypes.string, + totalcontributions: PropTypes.numberstring, + }), + ), +}; diff --git a/frontend/src/components/partnerMapswipeStats/timeSpentContributing.js b/frontend/src/components/partnerMapswipeStats/timeSpentContributing.js new file mode 100644 index 0000000000..99e1b762aa --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/timeSpentContributing.js @@ -0,0 +1,180 @@ +import { useState, useEffect, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Chart } from 'chart.js/auto'; +import 'chartjs-adapter-date-fns'; +import PropTypes from 'prop-types'; +import { parse, format } from 'date-fns'; + +import { CHART_COLOURS } from '../../config'; +import { formatSecondsToTwoUnits } from './overview'; +import { EmptySetIcon } from '../svgIcons'; +import messages from './messages'; + +export const TimeSpentContributing = ({ contributionTimeByDate = [] }) => { + const [chartDistribution, setChartDistribution] = useState('day'); // "day" or "month" + const chartRef = useRef(null); + const chartInstance = useRef(null); + + const aggregateTimeSpentContributingByMonth = (data) => { + const aggregatedData = {}; + + data.forEach((item) => { + const date = parse(item.date, 'yyyy-MM-dd', new Date()); + const monthKey = format(date, 'yyyy-MM'); + + if (!aggregatedData[monthKey]) { + aggregatedData[monthKey] = { + date: format(date, 'MMM yyyy'), + totalcontributionTime: 0, + }; + } + + aggregatedData[monthKey].totalcontributionTime += item.totalcontributionTime; + }); + + return Object.values(aggregatedData).sort((a, b) => new Date(a.date) - new Date(b.date)); + }; + + useEffect(() => { + if (!chartRef.current) return; + + const context = chartRef.current.getContext('2d'); + if (!chartInstance.current) { + chartInstance.current = new Chart(context, { + type: 'line', + data: getChartDataConfig(), + options: getChartOptions(), + }); + } + + return () => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (chartInstance.current) { + chartInstance.current.data = getChartDataConfig(); + chartInstance.current.options = getChartOptions(); + chartInstance.current.update(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartDistribution]); + + const getChartDataConfig = () => { + const context = chartRef.current.getContext('2d'); + // Create gradient for the area + const gradient = context.createLinearGradient(0, 0, 0, 450); + gradient.addColorStop(0, `rgba(215, 63, 63, 0.5)`); + gradient.addColorStop(1, 'rgba(215, 63, 63, 0)'); + + const data = + chartDistribution === 'day' + ? contributionTimeByDate + : aggregateTimeSpentContributingByMonth(contributionTimeByDate); + + return { + labels: data.map((entry) => entry.date), + datasets: [ + { + label: 'Time Spent', + backgroundColor: gradient, + borderColor: CHART_COLOURS.red, + borderWidth: 1.5, + data: data.map((entry) => entry.totalcontributionTime), + fill: true, + lineTension: chartDistribution === 'day' ? 0.1 : 0.4, + }, + ], + }; + }; + + const getChartOptions = () => { + return { + responsive: true, + maintainAspectRatio: false, + legend: { display: false }, + scales: { + x: + chartDistribution === 'day' + ? { + type: 'time', + time: { + unit: 'day', + tooltipFormat: 'MMM d, yyyy', + }, + } + : {}, + y: { + beginAtZero: true, + ticks: { + callback: function (value) { + return formatSecondsToTwoUnits(value, true); + }, + stepSize: 36 * 60, + }, + }, + }, + plugins: { + tooltip: { + callbacks: { + label: function (context) { + const value = context.parsed.y; + return formatSecondsToTwoUnits(value, true); + }, + }, + }, + }, + }; + }; + + return ( +
+
+

+ +

+
+ + +
+
+ +
+ + {contributionTimeByDate.length === 0 && ( +
+ +

No data found

+
+ )} +
+
+ ); +}; + +TimeSpentContributing.propTypes = { + contributionTimeByDate: PropTypes.arrayOf( + PropTypes.shape({ + totalcontributionTime: PropTypes.number, + date: PropTypes.string, + }), + ), +}; diff --git a/frontend/src/components/partnerMapswipeStats/timeSpentContributingByDay.js b/frontend/src/components/partnerMapswipeStats/timeSpentContributingByDay.js new file mode 100644 index 0000000000..a597b8ab46 --- /dev/null +++ b/frontend/src/components/partnerMapswipeStats/timeSpentContributingByDay.js @@ -0,0 +1,124 @@ +import { useEffect, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Chart } from 'chart.js/auto'; +import PropTypes from 'prop-types'; +import { parse, getDay } from 'date-fns'; + +import { CHART_COLOURS } from '../../config'; +import { formatSecondsToTwoUnits } from './overview'; +import { EmptySetIcon } from '../svgIcons'; +import messages from './messages'; + +export const TimeSpentContributingByDay = ({ contributionTimeByDate = [] }) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + useEffect(() => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + + const chartData = aggregateContributionTimeByWeekday(); + + chartInstance.current = new Chart(chartRef.current.getContext('2d'), { + type: 'bar', + data: { + labels: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + datasets: [ + { + label: 'Time Spent', + backgroundColor: `rgba(215, 63, 63, 0.4)`, + borderWidth: 2, + borderColor: CHART_COLOURS.red, + data: chartData.map((entry) => entry.totalcontributionTime), + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + legend: { display: false }, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: function (value) { + return formatSecondsToTwoUnits(value, true); + }, + stepSize: 48 * 60, + }, + }, + }, + plugins: { + tooltip: { + callbacks: { + label: function (context) { + const value = context.parsed.y; + return formatSecondsToTwoUnits(value, true); + }, + }, + }, + }, + }, + }); + + return () => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const aggregateContributionTimeByWeekday = () => { + const aggregatedData = {}; + const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + // Initialize aggregatedData with all weekdays + weekdays.forEach((day, index) => { + aggregatedData[index] = { + weekday: day, + totalcontributionTime: 0, + }; + }); + + contributionTimeByDate.forEach((item) => { + const date = parse(item.date, 'yyyy-MM-dd', new Date()); + const weekdayIndex = getDay(date); + + aggregatedData[weekdayIndex].totalcontributionTime += item.totalcontributionTime; + }); + + // Convert to array and sort by weekday index + return Object.entries(aggregatedData) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([_, value]) => value); + }; + + return ( +
+

+ +

+ +
+ + {contributionTimeByDate.length === 0 && ( +
+ +

No data found

+
+ )} +
+
+ ); +}; + +TimeSpentContributingByDay.propTypes = { + contributionTimeByDate: PropTypes.arrayOf( + PropTypes.shape({ + totalcontributionTime: PropTypes.number, + date: PropTypes.string, + }), + ), +}; diff --git a/frontend/src/components/statsCard.js b/frontend/src/components/statsCard.js index a0777314e6..cba58debd9 100644 --- a/frontend/src/components/statsCard.js +++ b/frontend/src/components/statsCard.js @@ -1,4 +1,6 @@ import { FormattedNumber } from 'react-intl'; +import PropTypes from 'prop-types'; + import shortNumber from 'short-number'; export const StatsCard = ({ icon, description, value, className, invertColors = false }) => { @@ -19,6 +21,55 @@ export const StatsCard = ({ icon, description, value, className, invertColors = ); }; +export const StatsCardWithFooter = ({ + icon, + description, + value, + className, + delta, + invertColors = false, + style, +}) => { + return ( +
+
+
{icon}
+ : value + } + label={description} + className="w-70 pt3-m mb1 fl" + invertColors={invertColors} + /> +
+ {delta ? ( +
+ {delta} +
+ ) : null} +
+ ); +}; + +StatsCardWithFooter.propTypes = { + icon: PropTypes.node, + description: PropTypes.node, + value: PropTypes.node, + className: PropTypes.string, + delta: PropTypes.node, + invertColors: PropTypes.bool, + style: PropTypes.object, +}; + export const StatsCardContent = ({ value, label, className, invertColors = false }: Object) => (

{value}

@@ -26,6 +77,20 @@ export const StatsCardContent = ({ value, label, className, invertColors = false
); +export const StatsCardWithFooterContent = ({ value, label, className, invertColors = false }) => ( +
+

{value}

+ {label} +
+); + +StatsCardWithFooterContent.propTypes = { + value: PropTypes.node, + label: PropTypes.node, + className: PropTypes.string, + invertColors: PropTypes.bool, +}; + function getFormattedNumber(num) { if (typeof num !== 'number') return '-'; const value = shortNumber(num); diff --git a/frontend/src/components/svgIcons/checksGrid.js b/frontend/src/components/svgIcons/checksGrid.js new file mode 100644 index 0000000000..13765e4e64 --- /dev/null +++ b/frontend/src/components/svgIcons/checksGrid.js @@ -0,0 +1,14 @@ +import { PureComponent } from 'react'; + +export class ChecksGridIcon extends PureComponent { + render() { + return ( + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/columnsGap.js b/frontend/src/components/svgIcons/columnsGap.js new file mode 100644 index 0000000000..c6c50c6b1d --- /dev/null +++ b/frontend/src/components/svgIcons/columnsGap.js @@ -0,0 +1,14 @@ +import { PureComponent } from 'react'; + +export class ColumnsGapIcon extends PureComponent { + render() { + return ( + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/emptySet.js b/frontend/src/components/svgIcons/emptySet.js new file mode 100644 index 0000000000..4ff8f5d2e8 --- /dev/null +++ b/frontend/src/components/svgIcons/emptySet.js @@ -0,0 +1,25 @@ +import { PureComponent } from 'react'; + +export class EmptySetIcon extends PureComponent { + render() { + return ( + + + + + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/fullscreen.js b/frontend/src/components/svgIcons/fullscreen.js new file mode 100644 index 0000000000..7119bdf953 --- /dev/null +++ b/frontend/src/components/svgIcons/fullscreen.js @@ -0,0 +1,14 @@ +import { PureComponent } from 'react'; + +export class FullscreenIcon extends PureComponent { + render() { + return ( + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/index.js b/frontend/src/components/svgIcons/index.js index 08f80f5233..134c41a9ba 100644 --- a/frontend/src/components/svgIcons/index.js +++ b/frontend/src/components/svgIcons/index.js @@ -70,6 +70,7 @@ export { EnvelopeIcon } from './envelope'; export { LinkedinIcon } from './linkedin'; export { MarkerIcon } from './marker'; export { ZoomPlusIcon } from './zoomPlus'; +export { ZoomMinusIcon } from './zoomMinus'; export { SidebarIcon } from './sidebar'; export { QuestionCircleIcon } from './questionCircle'; export { ChartLineIcon } from './chart'; @@ -85,3 +86,9 @@ export { DownloadIcon } from './download'; export { CircleMinusIcon } from './circleMinus'; export { CircleExclamationIcon } from './circleExclamation'; export { TableListIcon } from './tableList'; +export { SwipeIcon } from './swipe'; +export { EmptySetIcon } from './emptySet'; +export { PeopleIcon } from './people'; +export { ChecksGridIcon } from './checksGrid'; +export { ColumnsGapIcon } from './columnsGap'; +export { FullscreenIcon } from './fullscreen'; diff --git a/frontend/src/components/svgIcons/people.js b/frontend/src/components/svgIcons/people.js new file mode 100644 index 0000000000..5c227b77ac --- /dev/null +++ b/frontend/src/components/svgIcons/people.js @@ -0,0 +1,14 @@ +import { PureComponent } from 'react'; + +export class PeopleIcon extends PureComponent { + render() { + return ( + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/swipe.js b/frontend/src/components/svgIcons/swipe.js new file mode 100644 index 0000000000..0d313f154f --- /dev/null +++ b/frontend/src/components/svgIcons/swipe.js @@ -0,0 +1,31 @@ +import { PureComponent } from 'react'; + +export class SwipeIcon extends PureComponent { + render() { + return ( + + + + + + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/zoomMinus.js b/frontend/src/components/svgIcons/zoomMinus.js new file mode 100644 index 0000000000..86628fd29b --- /dev/null +++ b/frontend/src/components/svgIcons/zoomMinus.js @@ -0,0 +1,21 @@ +import { PureComponent } from 'react'; + +export class ZoomMinusIcon extends PureComponent { + render() { + return ( + + + + + + ); + } +} diff --git a/frontend/src/config/index.js b/frontend/src/config/index.js index b5b11362c9..bbcebb2f59 100644 --- a/frontend/src/config/index.js +++ b/frontend/src/config/index.js @@ -79,6 +79,7 @@ export const CHART_COLOURS = { blue: '#3389D6', orange: '#f09733', white: '#fff', + gray: '#C9C9C9', }; const fallbackRasterStyle = { diff --git a/frontend/src/views/messages.js b/frontend/src/views/messages.js index 782dc5ab87..072281cd87 100644 --- a/frontend/src/views/messages.js +++ b/frontend/src/views/messages.js @@ -814,4 +814,20 @@ export default defineMessages({ id: 'pages.create_campaign.error', defaultMessage: 'There was an error saving this campaign.', }, + mapswipeInfo: { + id: 'management.partner.stats.mapswipe.info', + defaultMessage: 'It may take up to 48 hours for updates', + }, + swipes: { + id: 'management.partners.stats.mapswipe.swipes', + defaultMessage: 'Swipes', + }, + timeSpentContributing: { + id: 'management.partners.stats.mapswipe.timeSpentContributing', + defaultMessage: 'Time Spent Contributing', + }, + partnersMapswipeStatsError: { + id: 'management.partners.stats.mapswipe.page.error', + defaultMessage: 'Something went wrong! Could not load data.', + }, }); diff --git a/frontend/src/views/partnersMapswipeStats.css b/frontend/src/views/partnersMapswipeStats.css new file mode 100644 index 0000000000..3bf7e57d23 --- /dev/null +++ b/frontend/src/views/partnersMapswipeStats.css @@ -0,0 +1,17 @@ +.mapswipe-stats-info-banner { + background-color: #d9d7d7; + width: fit-content; + margin-left: 20px; + border-radius: 3px; +} + +.mapswipe-stats-info-banner::before { + content: ''; + position: absolute; + left: -20px; + top: 0; + bottom: 0; + width: 20px; + background-color: #d9d7d7; + clip-path: polygon(100% 0, 0 50%, 100% 100%); +} diff --git a/frontend/src/views/partnersMapswipeStats.js b/frontend/src/views/partnersMapswipeStats.js new file mode 100644 index 0000000000..c9015d3640 --- /dev/null +++ b/frontend/src/views/partnersMapswipeStats.js @@ -0,0 +1,176 @@ +import { FormattedMessage } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import ReactPlaceholder from 'react-placeholder'; +import { useQuery } from '@tanstack/react-query'; + +import { InfoIcon, BanIcon } from '../components/svgIcons'; +import { + Overview, + getShortNumber, + formatSecondsToTwoUnits, +} from '../components/partnerMapswipeStats/overview'; +import { GroupMembers } from '../components/partnerMapswipeStats/groupMembers'; +import { ContributionsGrid } from '../components/partnerMapswipeStats/contributionsGrid'; +import { ContributionsHeatmap } from '../components/partnerMapswipeStats/contributionsHeatmap'; +import { TimeSpentContributing } from '../components/partnerMapswipeStats/timeSpentContributing'; +import { TimeSpentContributingByDay } from '../components/partnerMapswipeStats/timeSpentContributingByDay'; +import { ProjectTypeAreaStats } from '../components/partnerMapswipeStats/projectTypeAreaStats'; +import { SwipesByProjectType } from '../components/partnerMapswipeStats/swipesByProjectType'; +import { SwipesByOrganization } from '../components/partnerMapswipeStats/swipesByOrganization'; +import messages from './messages'; +import { fetchLocalJSONAPI } from '../network/genericJSONRequest'; +import './partnersMapswipeStats.css'; + +const PagePlaceholder = () => ( +
+
+ + +
+
+ + +
+
+); + +const InfoBanner = () => { + return ( +
+ + + + +
+ ); +}; + +export const PartnersMapswipeStats = () => { + const { id: partnerPermalink } = useParams(); + const { isLoading, isError, data, isRefetching } = useQuery({ + queryKey: ['partners-mapswipe-filtered-statistics', partnerPermalink], + queryFn: async () => { + const today = new Date(); + const currentYear = today.getFullYear(); + + const formatDate = (date) => { + const offset = date.getTimezoneOffset(); + const adjustedDate = new Date(date.getTime() - offset * 60 * 1000); + return adjustedDate.toISOString().split('T')[0]; + }; + + const fromDate = formatDate(new Date(currentYear, 0, 1)); + const endDate = formatDate(today); + + const response = await fetchLocalJSONAPI( + `partners/${partnerPermalink}/filtered-statistics/?fromDate=${fromDate}&toDate=${endDate}`, + ); + return response; + }, + }); + + const getSwipes = () => { + if (!data) return -; + if (data.contributionsByProjectType?.length === 0) return 0; + return getShortNumber( + data.contributionsByProjectType + .map((item) => item.totalcontributions) + .reduce((total, value) => total + value, 0), + ); + }; + + const getTimeSpentContributing = () => { + if (!data) return '-'; + if (data.contributionTimeByDate?.length === 0) return '0'; + return formatSecondsToTwoUnits( + data.contributionTimeByDate + .map((item) => item.totalcontributionTime) + .reduce((total, value) => total + value, 0), + true, + ); + }; + + return ( +
+ + + + } ready={!isLoading && !isRefetching}> + {!isLoading && isError ? ( +
+
+ +

+ +

+
+
+ ) : ( + <> +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ {getSwipes()} + + + +
+
+ {getTimeSpentContributing()} + + + +
+
+ +
+ + +
+ +
+ +
+ + )} +
+
+ ); +}; diff --git a/frontend/src/views/partnersStats.js b/frontend/src/views/partnersStats.js index 5673160345..0d255b9411 100644 --- a/frontend/src/views/partnersStats.js +++ b/frontend/src/views/partnersStats.js @@ -7,6 +7,7 @@ import messages from './messages'; import { NotFound } from './notFound'; import { useFetch } from '../hooks/UseFetch'; import { Leaderboard } from '../components/partners/leaderboard'; +import { PartnersMapswipeStats } from './partnersMapswipeStats'; import { Resources } from '../components/partners/partnersResources'; import { OHSOME_STATS_BASE_URL } from '../config'; import { Button } from '../components/button'; @@ -26,7 +27,10 @@ function getSocialIcons(link) { } } -const tabData = [{ id: 'leaderboard', title: 'Leaderboard' }]; +const tabData = [ + { id: 'leaderboard', title: 'Leaderboard' }, + { id: 'mapswipe', title: 'Map Swipe' }, +]; export const PartnersStats = () => { const { id, tabname } = useParams(); @@ -71,6 +75,8 @@ export const PartnersStats = () => { switch (tabname) { case 'leaderboard': return ; + case 'mapswipe': + return ; default: return <>; } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 8b7906c261..d62612116b 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7677,6 +7677,11 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" +h3-js@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/h3-js/-/h3-js-4.1.0.tgz#f8c4a8ad36612489a954f1a0bb3f4b7657d364e5" + integrity sha512-LQhmMl1dRQQjMXPzJc7MpZ/CqPOWWuAvVEoVJM9n/s7vHypj+c3Pd5rLQCkAsOgAoAYKbNCsYFE++LF7MvSfCQ== + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"