/delivery_performance_table",
+ DeliveryPerformanceTableView.as_view(),
+ name="delivery_performance_table",
+ ),
]
diff --git a/commcare_connect/program/views.py b/commcare_connect/program/views.py
index 79a80fc0..ff60a8b5 100644
--- a/commcare_connect/program/views.py
+++ b/commcare_connect/program/views.py
@@ -1,6 +1,6 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
-from django.shortcuts import get_object_or_404, redirect
+from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from django.views.generic import ListView, UpdateView
@@ -10,8 +10,14 @@
from commcare_connect.organization.decorators import org_admin_required, org_program_manager_required
from commcare_connect.organization.models import Organization
from commcare_connect.program.forms import ManagedOpportunityInitForm, ProgramForm
+from commcare_connect.program.helpers import get_annotated_managed_opportunity, get_delivery_performance_report
from commcare_connect.program.models import ManagedOpportunity, Program, ProgramApplication, ProgramApplicationStatus
-from commcare_connect.program.tables import ProgramApplicationTable, ProgramTable
+from commcare_connect.program.tables import (
+ DeliveryPerformanceTable,
+ FunnelPerformanceTable,
+ ProgramApplicationTable,
+ ProgramTable,
+)
class ProgramManagerMixin(LoginRequiredMixin, UserPassesTestMixin):
@@ -236,3 +242,38 @@ def apply_or_decline_application(request, application_id, action, org_slug=None,
messages.success(request, action_map[action]["message"])
return redirect(redirect_url)
+
+
+@org_program_manager_required
+def dashboard(request, **kwargs):
+ program = get_object_or_404(Program, id=kwargs.get("pk"), organization=request.org)
+ context = {
+ "program": program,
+ }
+ return render(request, "program/dashboard.html", context)
+
+
+class FunnelPerformanceTableView(ProgramManagerMixin, SingleTableView):
+ model = ManagedOpportunity
+ paginate_by = 10
+ table_class = FunnelPerformanceTable
+ template_name = "tables/single_table.html"
+
+ def get_queryset(self):
+ program_id = self.kwargs["pk"]
+ program = get_object_or_404(Program, id=program_id)
+ return get_annotated_managed_opportunity(program)
+
+
+class DeliveryPerformanceTableView(ProgramManagerMixin, SingleTableView):
+ model = ManagedOpportunity
+ paginate_by = 10
+ table_class = DeliveryPerformanceTable
+ template_name = "tables/single_table.html"
+
+ def get_queryset(self):
+ program_id = self.kwargs["pk"]
+ program = get_object_or_404(Program, id=program_id)
+ start_date = self.request.GET.get("start_date") or None
+ end_date = self.request.GET.get("end_date") or None
+ return get_delivery_performance_report(program, start_date, end_date)
diff --git a/commcare_connect/reports/queries.py b/commcare_connect/reports/queries.py
new file mode 100644
index 00000000..ed9ddf91
--- /dev/null
+++ b/commcare_connect/reports/queries.py
@@ -0,0 +1,34 @@
+from django.db.models import F
+from django.db.models.fields.json import KT
+
+
+def get_visit_map_queryset(base_queryset):
+ return (
+ base_queryset.annotate(
+ deliver_unit_name=F("deliver_unit__name"),
+ username_connectid=KT("form_json__metadata__username"),
+ timestart_str=KT("form_json__metadata__timeStart"),
+ timeend_str=KT("form_json__metadata__timeEnd"),
+ location_str=KT("form_json__metadata__location"),
+ )
+ .select_related("deliver_unit", "opportunity", "opportunity__delivery_type", "opportunity__organization")
+ .values(
+ "opportunity_id",
+ "opportunity__delivery_type__name",
+ "opportunity__delivery_type__slug",
+ "opportunity__organization__slug",
+ "opportunity__organization__name",
+ "xform_id",
+ "visit_date",
+ "username_connectid",
+ "deliver_unit_name",
+ "entity_id",
+ "status",
+ "flagged",
+ "flag_reason",
+ "reason",
+ "timestart_str",
+ "timeend_str",
+ "location_str",
+ )
+ )
diff --git a/commcare_connect/reports/tests/test_reports.py b/commcare_connect/reports/tests/test_reports.py
index 632a43ff..441090f7 100644
--- a/commcare_connect/reports/tests/test_reports.py
+++ b/commcare_connect/reports/tests/test_reports.py
@@ -40,7 +40,7 @@ def test_delivery_stats(opportunity: Opportunity):
status=VisitValidationStatus.approved.value,
opportunity_access=access,
completed_work=completed_work,
- visit_date=Faker("date_this_month"),
+ visit_date=Faker("date_time_this_month", tzinfo=datetime.UTC),
)
quarter = math.ceil(datetime.datetime.utcnow().month / 12 * 4)
@@ -65,19 +65,32 @@ def test_delivery_stats(opportunity: Opportunity):
def test_results_to_geojson():
+ class MockQuerySet:
+ def __init__(self, results):
+ self.results = results
+
+ def all(self):
+ return self.results
+
# Test input
- results = [
- {"gps_location_long": "10.123", "gps_location_lat": "20.456", "status": "approved", "other_field": "value1"},
- {"gps_location_long": "30.789", "gps_location_lat": "40.012", "status": "rejected", "other_field": "value2"},
- {"gps_location_long": "invalid", "gps_location_lat": "50.678", "status": "unknown", "other_field": "value3"},
- {"status": "approved", "other_field": "value4"}, # Case where lat/lon are not present
- { # Case where lat/lon are null
- "gps_location_long": None,
- "gps_location_lat": None,
- "status": "rejected",
- "other_field": "value5",
- },
- ]
+ results = MockQuerySet(
+ [
+ {"location_str": "20.456 10.123 0 0", "status": "approved", "other_field": "value1"},
+ {"location_str": "40.012 30.789", "status": "rejected", "other_field": "value2"},
+ {"location_str": "invalid location", "status": "unknown", "other_field": "value3"},
+ {"location_str": "bad location", "status": "unknown", "other_field": "value4"},
+ {
+ "location_str": None,
+ "status": "approved",
+ "other_field": "value5",
+ }, # Case where lat/lon are not present
+ { # Case where lat/lon are null
+ "location_str": None,
+ "status": "rejected",
+ "other_field": "value5",
+ },
+ ]
+ )
# Call the function
geojson = _results_to_geojson(results)
@@ -93,7 +106,7 @@ def test_results_to_geojson():
assert feature1["geometry"]["coordinates"] == [10.123, 20.456]
assert feature1["properties"]["status"] == "approved"
assert feature1["properties"]["other_field"] == "value1"
- assert feature1["properties"]["color"] == "#00FF00"
+ assert feature1["properties"]["color"] == "#4ade80"
# Check the second feature
feature2 = geojson["features"][1]
@@ -102,7 +115,7 @@ def test_results_to_geojson():
assert feature2["geometry"]["coordinates"] == [30.789, 40.012]
assert feature2["properties"]["status"] == "rejected"
assert feature2["properties"]["other_field"] == "value2"
- assert feature2["properties"]["color"] == "#FF0000"
+ assert feature2["properties"]["color"] == "#f87171"
# Check that the other cases are not included
assert all(f["properties"]["other_field"] not in ["value3", "value4", "value5"] for f in geojson["features"])
diff --git a/commcare_connect/reports/urls.py b/commcare_connect/reports/urls.py
index 0b780e73..de9ca690 100644
--- a/commcare_connect/reports/urls.py
+++ b/commcare_connect/reports/urls.py
@@ -6,6 +6,8 @@
urlpatterns = [
path("program_dashboard", views.program_dashboard_report, name="program_dashboard_report"),
- path("api/visit_map_data/", views.visit_map_data, name="visit_map_data"),
path("delivery_stats", view=views.DeliveryStatsReportView.as_view(), name="delivery_stats_report"),
+ path("api/visit_map_data/", views.visit_map_data, name="visit_map_data"),
+ path("api/dashboard_stats/", views.dashboard_stats_api, name="dashboard_stats_api"),
+ path("api/dashboard_charts/", views.dashboard_charts_api, name="dashboard_charts_api"),
]
diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py
index ed60a988..6b890510 100644
--- a/commcare_connect/reports/views.py
+++ b/commcare_connect/reports/views.py
@@ -1,13 +1,15 @@
-from datetime import date, datetime
+from datetime import date, datetime, timedelta
import django_filters
import django_tables2 as tables
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import Column, Layout, Row
from django import forms
from django.conf import settings
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
-from django.db import connection
-from django.db.models import Max, Q, Sum
+from django.db.models import Count, Max, Q, Sum
+from django.db.models.functions import TruncDate
from django.http import JsonResponse
from django.shortcuts import render
from django.urls import reverse
@@ -16,7 +18,9 @@
from django_filters.views import FilterView
from commcare_connect.cache import quickcache
-from commcare_connect.opportunity.models import CompletedWork, CompletedWorkStatus, DeliveryType, Payment
+from commcare_connect.opportunity.models import CompletedWork, CompletedWorkStatus, DeliveryType, Payment, UserVisit
+from commcare_connect.organization.models import Organization
+from commcare_connect.reports.queries import get_visit_map_queryset
from .tables import AdminReportTable
@@ -151,14 +155,82 @@ def _get_table_data_for_quarter(quarter, delivery_type, group_by_delivery_type=F
return data
+class DashboardFilters(django_filters.FilterSet):
+ program = django_filters.ModelChoiceFilter(
+ queryset=DeliveryType.objects.all(),
+ field_name="opportunity__delivery_type",
+ label="Program",
+ empty_label="All Programs",
+ required=False,
+ )
+ organization = django_filters.ModelChoiceFilter(
+ queryset=Organization.objects.all(),
+ field_name="opportunity__organization",
+ label="Organization",
+ empty_label="All Organizations",
+ required=False,
+ )
+ from_date = django_filters.DateTimeFilter(
+ widget=forms.DateInput(attrs={"type": "date"}),
+ field_name="visit_date",
+ lookup_expr="gt",
+ label="From Date",
+ required=False,
+ )
+ to_date = django_filters.DateTimeFilter(
+ widget=forms.DateInput(attrs={"type": "date"}),
+ field_name="visit_date",
+ lookup_expr="lte",
+ label="To Date",
+ required=False,
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.form.helper = FormHelper()
+ self.form.helper.form_class = "form-inline"
+ self.form.helper.layout = Layout(
+ Row(
+ Column("program", css_class="col-md-3"),
+ Column("organization", css_class="col-md-3"),
+ Column("from_date", css_class="col-md-3"),
+ Column("to_date", css_class="col-md-3"),
+ )
+ )
+
+ # Set default values if no data is provided
+ if not self.data:
+ # Create a mutable copy of the QueryDict
+ self.data = self.data.copy() if self.data else {}
+
+ # Set default dates
+ today = date.today()
+ default_from = today - timedelta(days=30)
+
+ # Set the default values
+ self.data["to_date"] = today.strftime("%Y-%m-%d")
+ self.data["from_date"] = default_from.strftime("%Y-%m-%d")
+
+ # Force the form to bind with the default data
+ self.form.is_bound = True
+ self.form.data = self.data
+
+ class Meta:
+ model = UserVisit
+ fields = ["program", "organization", "from_date", "to_date"]
+
+
@login_required
-@user_passes_test(lambda user: user.is_superuser)
-@require_GET
+@user_passes_test(lambda u: u.is_superuser)
def program_dashboard_report(request):
+ filterset = DashboardFilters(request.GET)
return render(
request,
"reports/dashboard.html",
- context={"mapbox_token": settings.MAPBOX_TOKEN},
+ context={
+ "mapbox_token": settings.MAPBOX_TOKEN,
+ "filter": filterset,
+ },
)
@@ -166,20 +238,18 @@ def program_dashboard_report(request):
@user_passes_test(lambda user: user.is_superuser)
@require_GET
def visit_map_data(request):
- with connection.cursor() as cursor:
- # Read the SQL file
- with open("commcare_connect/reports/sql/visit_map.sql") as sql_file:
- sql_query = sql_file.read()
+ filterset = DashboardFilters(request.GET)
+
+ # Use the filtered queryset to calculate stats
- # Execute the query
- cursor.execute(sql_query)
+ queryset = UserVisit.objects.all()
+ if filterset.is_valid():
+ queryset = filterset.filter_queryset(queryset)
- # Fetch all results
- columns = [col[0] for col in cursor.description]
- results = [dict(zip(columns, row)) for row in cursor.fetchall()]
+ queryset = get_visit_map_queryset(queryset)
# Convert to GeoJSON
- geojson = _results_to_geojson(results)
+ geojson = _results_to_geojson(queryset)
# Return the GeoJSON as JSON response
return JsonResponse(geojson, safe=False)
@@ -188,17 +258,23 @@ def visit_map_data(request):
def _results_to_geojson(results):
geojson = {"type": "FeatureCollection", "features": []}
status_to_color = {
- "approved": "#00FF00",
- "rejected": "#FF0000",
+ "approved": "#4ade80",
+ "rejected": "#f87171",
}
- for result in results:
+ for i, result in enumerate(results.all()):
+ location_str = result.get("location_str")
# Check if both latitude and longitude are not None and can be converted to float
- if result.get("gps_location_long") and result.get("gps_location_lat"):
- try:
- longitude = float(result["gps_location_long"])
- latitude = float(result["gps_location_lat"])
- except ValueError:
- # Skip this result if conversion to float fails
+ if location_str:
+ split_location = location_str.split(" ")
+ if len(split_location) >= 2:
+ try:
+ longitude = float(split_location[1])
+ latitude = float(split_location[0])
+ except ValueError:
+ # Skip this result if conversion to float fails
+ continue
+ else:
+ # Or if the location string is not in the expected format
continue
feature = {
@@ -211,7 +287,7 @@ def _results_to_geojson(results):
key: value for key, value in result.items() if key not in ["gps_location_lat", "gps_location_long"]
},
}
- color = status_to_color.get(result.get("status", ""), "#FFFF00")
+ color = status_to_color.get(result.get("status", ""), "#fbbf24")
feature["properties"]["color"] = color
geojson["features"].append(feature)
@@ -330,3 +406,139 @@ def object_list(self):
data = _get_table_data_for_quarter(q, delivery_type, group_by_delivery_type)
table_data += data
return table_data
+
+
+@login_required
+@user_passes_test(lambda u: u.is_superuser)
+def dashboard_stats_api(request):
+ filterset = DashboardFilters(request.GET)
+
+ # Use the filtered queryset to calculate stats
+ queryset = UserVisit.objects.all()
+ if filterset.is_valid():
+ queryset = filterset.filter_queryset(queryset)
+
+ # Example stats calculation (adjust based on your needs)
+ active_users = queryset.values("opportunity_access__user").distinct().count()
+ total_visits = queryset.count()
+ verified_visits = queryset.filter(status=CompletedWorkStatus.approved).count()
+ percent_verified = round(float(verified_visits / total_visits) * 100, 1) if total_visits > 0 else 0
+
+ return JsonResponse(
+ {
+ "total_visits": total_visits,
+ "active_users": active_users,
+ "verified_visits": verified_visits,
+ "percent_verified": f"{percent_verified:.1f}%",
+ }
+ )
+
+
+@login_required
+@user_passes_test(lambda u: u.is_superuser)
+def dashboard_charts_api(request):
+ filterset = DashboardFilters(request.GET)
+ queryset = UserVisit.objects.all()
+ # Use the filtered queryset if available, else use last 30 days
+ if filterset.is_valid():
+ queryset = filterset.filter_queryset(queryset)
+ from_date = filterset.form.cleaned_data["from_date"]
+ to_date = filterset.form.cleaned_data["to_date"]
+ else:
+ to_date = datetime.now().date()
+ from_date = to_date - timedelta(days=30)
+ queryset = queryset.filter(visit_date__gte=from_date, visit_date__lte=to_date)
+
+ return JsonResponse(
+ {
+ "time_series": _get_time_series_data(queryset, from_date, to_date),
+ "program_pie": _get_program_pie_data(queryset),
+ "status_pie": _get_status_pie_data(queryset),
+ }
+ )
+
+
+def _get_time_series_data(queryset, from_date, to_date):
+ """Example output:
+ {
+ "labels": ["Jan 01", "Jan 02", "Jan 03"],
+ "datasets": [
+ {
+ "name": "Program A",
+ "data": [5, 3, 7]
+ },
+ {
+ "name": "Program B",
+ "data": [2, 4, 1]
+ }
+ ]
+ }
+ """
+ # Get visits over time by program
+ visits_by_program_time = (
+ queryset.values(
+ "opportunity__delivery_type__name",
+ visit_date_date=TruncDate("visit_date"),
+ )
+ .annotate(count=Count("id"))
+ .order_by("visit_date_date", "opportunity__delivery_type__name")
+ )
+
+ # Process time series data
+ program_data = {}
+ for visit in visits_by_program_time:
+ program_name = visit["opportunity__delivery_type__name"]
+ if program_name not in program_data:
+ program_data[program_name] = {}
+ program_data[program_name][visit["visit_date_date"]] = visit["count"]
+ # Create labels and datasets for time series
+ labels = []
+ time_datasets = []
+ current_date = from_date
+
+ while current_date <= to_date:
+ labels.append(current_date.strftime("%b %d"))
+ current_date += timedelta(days=1)
+
+ for program_name in program_data.keys():
+ data = []
+ current_date = from_date
+ while current_date <= to_date:
+ # Convert current_date to a date object to avoid timezones making comparisons fail
+ current_date_date = current_date.date()
+ data.append(program_data[program_name].get(current_date_date, 0))
+ current_date += timedelta(days=1)
+
+ time_datasets.append({"name": program_name or "Unknown", "data": data})
+
+ return {"labels": labels, "datasets": time_datasets}
+
+
+def _get_program_pie_data(queryset):
+ """Example output:
+ {
+ "labels": ["Program A", "Program B", "Unknown"],
+ "data": [10, 5, 2]
+ }
+ """
+ visits_by_program = (
+ queryset.values("opportunity__delivery_type__name").annotate(count=Count("id")).order_by("-count")
+ )
+ return {
+ "labels": [item["opportunity__delivery_type__name"] or "Unknown" for item in visits_by_program],
+ "data": [item["count"] for item in visits_by_program],
+ }
+
+
+def _get_status_pie_data(queryset):
+ """Example output:
+ {
+ "labels": ["Approved", "Pending", "Rejected", "Unknown"],
+ "data": [15, 8, 4, 1]
+ }
+ """
+ visits_by_status = queryset.values("status").annotate(count=Count("id")).order_by("-count")
+ return {
+ "labels": [item["status"] or "Unknown" for item in visits_by_status],
+ "data": [item["count"] for item in visits_by_status],
+ }
diff --git a/commcare_connect/static/js/dashboard.js b/commcare_connect/static/js/dashboard.js
new file mode 100644
index 00000000..53ca2b65
--- /dev/null
+++ b/commcare_connect/static/js/dashboard.js
@@ -0,0 +1,325 @@
+console.log('dashboard.js loaded');
+
+// colors to use for the categories
+// soft green, yellow, red
+const visitColors = ['#4ade80', '#fbbf24', '#f87171'];
+
+// after the GeoJSON data is loaded, update markers on the screen on every frame
+// objects for caching and keeping track of HTML marker objects (for performance)
+const markers = {};
+let markersOnScreen = {};
+
+function updateMarkers(map) {
+ const newMarkers = {};
+ const features = map.querySourceFeatures('visits');
+
+ // for every cluster on the screen, create an HTML marker for it (if we didn't yet),
+ // and add it to the map if it's not there already
+ for (const feature of features) {
+ const coords = feature.geometry.coordinates;
+ const props = feature.properties;
+ if (!props.cluster) continue;
+ const id = props.cluster_id;
+
+ let marker = markers[id];
+ if (!marker) {
+ const el = createDonutChart(
+ {
+ ...props,
+ cluster_id: id, // Make sure cluster_id is passed
+ coordinates: coords, // Pass the coordinates
+ },
+ map,
+ );
+ marker = markers[id] = new mapboxgl.Marker({
+ element: el,
+ }).setLngLat(coords);
+ }
+ newMarkers[id] = marker;
+
+ if (!markersOnScreen[id]) marker.addTo(map);
+ }
+ // for every marker we've added previously, remove those that are no longer visible
+ for (const id in markersOnScreen) {
+ if (!newMarkers[id]) markersOnScreen[id].remove();
+ }
+ markersOnScreen = newMarkers;
+}
+
+// Function to create a donut chart
+function createDonutChart(props, map) {
+ const offsets = [];
+ const counts = [props.approved, props.pending, props.rejected];
+ let total = 0;
+ for (const count of counts) {
+ offsets.push(total);
+ total += count;
+ }
+ const fontSize =
+ total >= 1000 ? 22 : total >= 100 ? 20 : total >= 10 ? 18 : 16;
+ const r = total >= 1000 ? 50 : total >= 100 ? 32 : total >= 10 ? 24 : 18;
+ const r0 = Math.round(r * 0.8);
+ const w = r * 2;
+
+ let html = `
+
+
`;
+
+ const el = document.createElement('div');
+ el.innerHTML = html;
+ el.style.cursor = 'pointer';
+
+ // Click handler to zoom and navigate to the cluster
+ el.addEventListener('click', (e) => {
+ map
+ .getSource('visits')
+ .getClusterExpansionZoom(props.cluster_id, (err, zoom) => {
+ if (err) return;
+
+ map.easeTo({
+ center: props.coordinates,
+ zoom: zoom,
+ });
+ });
+ });
+
+ return el;
+}
+
+// Function to create a donut segment
+function donutSegment(start, end, r, r0, color) {
+ if (end - start === 1) end -= 0.00001;
+ const a0 = 2 * Math.PI * (start - 0.25);
+ const a1 = 2 * Math.PI * (end - 0.25);
+ const x0 = Math.cos(a0),
+ y0 = Math.sin(a0);
+ const x1 = Math.cos(a1),
+ y1 = Math.sin(a1);
+ const largeArc = end - start > 0.5 ? 1 : 0;
+
+ // draw an SVG path
+ return ``;
+}
+
+const chartColors = [
+ { border: 'rgb(75, 192, 192)', background: 'rgba(75, 192, 192, 0.8)' },
+ { border: 'rgb(255, 99, 132)', background: 'rgba(255, 99, 132, 0.8)' },
+ { border: 'rgb(255, 205, 86)', background: 'rgba(255, 205, 86, 0.8)' },
+ { border: 'rgb(54, 162, 235)', background: 'rgba(54, 162, 235, 0.8)' },
+];
+
+const statusColors = {
+ approved: {
+ background: 'rgba(74, 222, 128, 0.8)',
+ border: 'rgb(74, 222, 128)',
+ },
+ rejected: {
+ background: 'rgba(248, 113, 113, 0.8)',
+ border: 'rgb(248, 113, 113)',
+ },
+ pending: {
+ background: 'rgba(251, 191, 36, 0.8)',
+ border: 'rgb(251, 191, 36)',
+ },
+};
+
+function createTimeSeriesChart(ctx, data) {
+ return new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: data.labels,
+ datasets: data.datasets.map((dataset, index) => ({
+ label: dataset.name,
+ data: dataset.data,
+ borderColor: chartColors[index % chartColors.length].border,
+ backgroundColor: chartColors[index % chartColors.length].background,
+ borderWidth: 1,
+ })),
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ tooltip: {
+ mode: 'index',
+ intersect: false,
+ },
+ },
+ scales: {
+ x: {
+ stacked: true,
+ title: {
+ display: true,
+ text: 'Date',
+ },
+ },
+ y: {
+ stacked: true,
+ beginAtZero: true,
+ title: {
+ display: true,
+ text: 'Number of Visits',
+ },
+ },
+ },
+ },
+ });
+}
+
+function createProgramPieChart(ctx, data) {
+ // Check if there's no data or empty data
+ if (!data?.data?.length) {
+ return new Chart(ctx, {
+ type: 'pie',
+ data: {
+ labels: ['No data'],
+ datasets: [
+ {
+ data: [1],
+ backgroundColor: ['rgba(156, 163, 175, 0.3)'],
+ borderColor: ['rgb(156, 163, 175)'],
+ borderWidth: 1,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 12,
+ color: 'rgb(156, 163, 175)',
+ },
+ },
+ },
+ },
+ });
+ }
+
+ return new Chart(ctx, {
+ type: 'pie',
+ data: {
+ labels: data.labels,
+ datasets: [
+ {
+ data: data.data,
+ backgroundColor: chartColors.map((c) => c.background),
+ borderColor: chartColors.map((c) => c.border),
+ borderWidth: 1,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 12,
+ },
+ },
+ },
+ },
+ });
+}
+
+function createStatusPieChart(ctx, data) {
+ // Check if there's no data or empty data
+ if (!data?.data?.length) {
+ return new Chart(ctx, {
+ type: 'pie',
+ data: {
+ labels: ['No data'],
+ datasets: [
+ {
+ data: [1],
+ backgroundColor: ['rgba(156, 163, 175, 0.3)'],
+ borderColor: ['rgb(156, 163, 175)'],
+ borderWidth: 1,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 12,
+ color: 'rgb(156, 163, 175)',
+ },
+ },
+ },
+ },
+ });
+ }
+
+ return new Chart(ctx, {
+ type: 'pie',
+ data: {
+ labels: data.labels,
+ datasets: [
+ {
+ data: data.data,
+ backgroundColor: data.labels.map(
+ (status) =>
+ statusColors[status]?.background || 'rgba(156, 163, 175, 0.8)',
+ ),
+ borderColor: data.labels.map(
+ (status) => statusColors[status]?.border || 'rgb(156, 163, 175)',
+ ),
+ borderWidth: 1,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 12,
+ },
+ },
+ },
+ },
+ });
+}
+
+window.updateMarkers = updateMarkers;
+window.createDonutChart = createDonutChart;
+window.createTimeSeriesChart = createTimeSeriesChart;
+window.createProgramPieChart = createProgramPieChart;
+window.createStatusPieChart = createStatusPieChart;
diff --git a/commcare_connect/static/js/project.js b/commcare_connect/static/js/project.js
index 83ba289b..ef6e4946 100644
--- a/commcare_connect/static/js/project.js
+++ b/commcare_connect/static/js/project.js
@@ -24,13 +24,22 @@ window.circle = circle;
* @param {Array.<{lng: float, lat: float, precision: float}> visit_data - Visit location data for User
*/
function addAccuracyCircles(map, visit_data) {
- map.on('load', () => {
- const visit_accuracy_circles = [];
- visit_data.forEach((loc) => {
- visit_accuracy_circles.push(
- circle([loc.lng, loc.lat], loc.precision, { units: 'meters' }),
- );
+ const FILL_OPACITY = 0.1;
+ const OUTLINE_COLOR = '#fcbf49';
+ const OUTLINE_WIDTH = 3;
+ const OUTLINE_OPACITY = 0.5;
+
+ const visit_accuracy_circles = visit_data.map((loc) =>
+ circle([loc.lng, loc.lat], loc.precision, { units: 'meters' }),
+ );
+
+ // Check if the source exists, then update or add the source
+ if (map.getSource('visit_accuracy_circles')) {
+ map.getSource('visit_accuracy_circles').setData({
+ type: 'FeatureCollection',
+ features: visit_accuracy_circles,
});
+ } else {
map.addSource('visit_accuracy_circles', {
type: 'geojson',
data: {
@@ -45,21 +54,22 @@ function addAccuracyCircles(map, visit_data) {
type: 'fill',
paint: {
'fill-antialias': true,
- 'fill-opacity': 0.3,
+ 'fill-opacity': FILL_OPACITY,
},
});
+ // Add the outline layer
map.addLayer({
id: 'visit-accuracy-circle-outlines-layer',
source: 'visit_accuracy_circles',
type: 'line',
paint: {
- 'line-color': '#fcbf49',
- 'line-width': 3,
- 'line-opacity': 0.5,
+ 'line-color': OUTLINE_COLOR,
+ 'line-width': OUTLINE_WIDTH,
+ 'line-opacity': OUTLINE_OPACITY,
},
});
- });
+ }
}
window.addAccuracyCircles = addAccuracyCircles;
@@ -67,16 +77,21 @@ window.addAccuracyCircles = addAccuracyCircles;
function addCatchmentAreas(map, catchments) {
const ACTIVE_COLOR = '#3366ff';
const INACTIVE_COLOR = '#ff4d4d';
- const CIRCLE_OPACITY = 0.3;
+ const CIRCLE_OPACITY = 0.15;
- map.on('load', () => {
- const catchmentCircles = catchments.map((catchment) =>
- circle([catchment.lng, catchment.lat], catchment.radius, {
- units: 'meters',
- properties: { active: catchment.active },
- }),
- );
+ const catchmentCircles = catchments.map((catchment) =>
+ circle([catchment.lng, catchment.lat], catchment.radius, {
+ units: 'meters',
+ properties: { active: catchment.active },
+ }),
+ );
+ if (map.getSource('catchment_circles')) {
+ map.getSource('catchment_circles').setData({
+ type: 'FeatureCollection',
+ features: catchmentCircles,
+ });
+ } else {
map.addSource('catchment_circles', {
type: 'geojson',
data: {
@@ -105,17 +120,17 @@ function addCatchmentAreas(map, catchments) {
'line-opacity': 0.5,
},
});
+ }
- if (catchments?.length) {
- window.Alpine.nextTick(() => {
- const legendElement = document.getElementById('legend');
- if (legendElement) {
- const legendData = window.Alpine.$data(legendElement);
- legendData.show = true;
- }
- });
- }
- });
+ if (catchments?.length) {
+ window.Alpine.nextTick(() => {
+ const legendElement = document.getElementById('legend');
+ if (legendElement) {
+ const legendData = window.Alpine.$data(legendElement);
+ legendData.show = true;
+ }
+ });
+ }
}
window.addCatchmentAreas = addCatchmentAreas;
diff --git a/commcare_connect/templates/opportunity/opportunity_detail.html b/commcare_connect/templates/opportunity/opportunity_detail.html
index eeeb7dd8..b1aef96d 100644
--- a/commcare_connect/templates/opportunity/opportunity_detail.html
+++ b/commcare_connect/templates/opportunity/opportunity_detail.html
@@ -364,7 +364,7 @@