From 1064b98263c32f1ad3ad395a036bdcda3b8239c3 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 30 Oct 2024 10:43:04 +0530 Subject: [PATCH 01/44] filtered credentails on the basis of current org --- commcare_connect/connect_id_client/main.py | 7 +- commcare_connect/opportunity/forms.py | 8 +- .../opportunity/tests/test_forms.py | 198 +++++++++++++++++- commcare_connect/opportunity/views.py | 7 +- commcare_connect/organization/views.py | 4 +- 5 files changed, 215 insertions(+), 9 deletions(-) diff --git a/commcare_connect/connect_id_client/main.py b/commcare_connect/connect_id_client/main.py index 1f5307a1..7f117d9f 100644 --- a/commcare_connect/connect_id_client/main.py +++ b/commcare_connect/connect_id_client/main.py @@ -54,8 +54,11 @@ def add_credential(organization: Organization, credential: str, users: list[str] return -def fetch_credentials(): - response = _make_request(GET, "/users/fetch_credentials") +def fetch_credentials(org_slug=None): + params = {} + if org_slug: + params["org_slug"] = org_slug + response = _make_request(GET, "/users/fetch_credentials", params=params) data = response.json() return [Credential(**c) for c in data["credentials"]] diff --git a/commcare_connect/opportunity/forms.py b/commcare_connect/opportunity/forms.py index 3e472450..7c567d89 100644 --- a/commcare_connect/opportunity/forms.py +++ b/commcare_connect/opportunity/forms.py @@ -33,7 +33,8 @@ class OpportunityUserInviteForm(forms.Form): def __init__(self, *args, **kwargs): - credentials = connect_id_client.fetch_credentials() + org_slug = kwargs.pop("org_slug", None) + credentials = connect_id_client.fetch_credentials(org_slug) super().__init__(*args, **kwargs) self.helper = FormHelper(self) @@ -73,7 +74,10 @@ def clean_users(self): return split_users -class OpportunityChangeForm(forms.ModelForm, OpportunityUserInviteForm): +class OpportunityChangeForm( + OpportunityUserInviteForm, + forms.ModelForm, +): class Meta: model = Opportunity fields = [ diff --git a/commcare_connect/opportunity/tests/test_forms.py b/commcare_connect/opportunity/tests/test_forms.py index a8f422be..58f6db95 100644 --- a/commcare_connect/opportunity/tests/test_forms.py +++ b/commcare_connect/opportunity/tests/test_forms.py @@ -5,8 +5,8 @@ import pytest from factory.fuzzy import FuzzyDate, FuzzyText -from commcare_connect.opportunity.forms import OpportunityCreationForm -from commcare_connect.opportunity.tests.factories import ApplicationFactory +from commcare_connect.opportunity.forms import OpportunityChangeForm, OpportunityCreationForm +from commcare_connect.opportunity.tests.factories import ApplicationFactory, CommCareAppFactory, OpportunityFactory class TestOpportunityCreationForm: @@ -111,3 +111,197 @@ def test_save(self, user, organization): ) form.is_valid() form.save() + + +@pytest.mark.django_db +class TestOpportunityChangeForm: + @pytest.fixture(autouse=True) + def setup_credentials_mock(self, monkeypatch): + self.mock_credentials = [ + type("Credential", (), {"slug": "cert1", "name": "Work for test"}), + type("Credential", (), {"slug": "cert2", "name": "Work for test"}), + ] + monkeypatch.setattr( + "commcare_connect.connect_id_client.fetch_credentials", lambda org_slug: self.mock_credentials + ) + + @pytest.fixture + def valid_opportunity(self, organization): + return OpportunityFactory( + organization=organization, + active=True, + learn_app=CommCareAppFactory(cc_app_id="test_learn_app"), + deliver_app=CommCareAppFactory(cc_app_id="test_deliver_app"), + name="Test Opportunity", + description="Test Description", + short_description="Short Description", + currency="USD", + is_test=False, + end_date=datetime.date.today() + datetime.timedelta(days=30), + ) + + @pytest.fixture + def base_form_data(self, valid_opportunity): + return { + "name": "Updated Opportunity", + "description": "Updated Description", + "short_description": "Updated Short Description", + "active": True, + "currency": "EUR", + "is_test": False, + "delivery_type": valid_opportunity.delivery_type.id, + "additional_users": 5, + "end_date": (datetime.date.today() + datetime.timedelta(days=60)).isoformat(), + "users": "+1234567890\n+9876543210", + "filter_country": "US", + "filter_credential": "cert1", + } + + def test_form_initialization(self, valid_opportunity, organization): + form = OpportunityChangeForm(instance=valid_opportunity, org_slug=organization.slug) + expected_fields = { + "name", + "description", + "short_description", + "active", + "currency", + "is_test", + "delivery_type", + "additional_users", + "end_date", + "users", + "filter_country", + "filter_credential", + } + assert all(field in form.fields for field in expected_fields) + + expected_initial = { + "name": valid_opportunity.name, + "description": valid_opportunity.description, + "short_description": valid_opportunity.short_description, + "active": valid_opportunity.active, + "currency": valid_opportunity.currency, + "is_test": valid_opportunity.is_test, + "delivery_type": valid_opportunity.delivery_type.id, + "end_date": valid_opportunity.end_date.isoformat(), + "filter_country": [""], + "filter_credential": [""], + } + assert all(form.initial.get(key) == value for key, value in expected_initial.items()) + + @pytest.mark.parametrize( + "field", + [ + "name", + "description", + "short_description", + "currency", + ], + ) + def test_required_fields(self, valid_opportunity, organization, field, base_form_data): + data = base_form_data.copy() + data[field] = "" + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + assert not form.is_valid() + assert field in form.errors + + @pytest.mark.parametrize( + "test_data", + [ + pytest.param( + { + "field": "additional_users", + "value": "invalid", + "error_expected": True, + "error_message": "Enter a whole number.", + }, + id="invalid_additional_users", + ), + pytest.param( + { + "field": "end_date", + "value": "invalid-date", + "error_expected": True, + "error_message": "Enter a valid date.", + }, + id="invalid_end_date", + ), + pytest.param( + { + "field": "users", + "value": " +1234567890 \n +9876543210 ", + "error_expected": False, + "expected_clean": ["+1234567890", "+9876543210"], + }, + id="valid_users_with_whitespace", + ), + ], + ) + def test_field_validation(self, valid_opportunity, organization, base_form_data, test_data): + data = base_form_data.copy() + data[test_data["field"]] = test_data["value"] + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + if test_data["error_expected"]: + assert not form.is_valid() + assert test_data["error_message"] in str(form.errors[test_data["field"]]) + else: + assert form.is_valid() + if "expected_clean" in test_data: + assert form.cleaned_data[test_data["field"]] == test_data["expected_clean"] + + @pytest.mark.parametrize( + "app_scenario", + [ + pytest.param( + { + "active_app_ids": ("unique_app1", "unique_app2"), + "new_app_ids": ("different_app1", "different_app2"), + "expected_valid": True, + }, + id="unique_apps", + ), + pytest.param( + { + "active_app_ids": ("shared_app1", "shared_app2"), + "new_app_ids": ("shared_app1", "shared_app2"), + "expected_valid": False, + }, + id="reused_apps", + ), + ], + ) + def test_app_reuse_validation(self, organization, base_form_data, app_scenario): + OpportunityFactory( + organization=organization, + active=True, + learn_app=CommCareAppFactory(cc_app_id=app_scenario["active_app_ids"][0]), + deliver_app=CommCareAppFactory(cc_app_id=app_scenario["active_app_ids"][1]), + ) + + inactive_opp = OpportunityFactory( + organization=organization, + active=False, + learn_app=CommCareAppFactory(cc_app_id=app_scenario["new_app_ids"][0]), + deliver_app=CommCareAppFactory(cc_app_id=app_scenario["new_app_ids"][1]), + ) + + form = OpportunityChangeForm(data=base_form_data, instance=inactive_opp, org_slug=organization.slug) + + assert form.is_valid() == app_scenario["expected_valid"] + if not app_scenario["expected_valid"]: + assert "Cannot reactivate opportunity with reused applications" in str(form.errors["active"]) + + @pytest.mark.parametrize( + "data_updates,expected_valid", + [ + ({"currency": "USD", "additional_users": 5}, True), + ({"currency": "EUR", "additional_users": 10}, True), + ({"currency": "INVALID", "additional_users": 5}, False), + ({"currency": "USD", "additional_users": -5}, True), + ], + ) + def test_valid_combinations(self, valid_opportunity, organization, base_form_data, data_updates, expected_valid): + data = base_form_data.copy() + data.update(data_updates) + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + assert form.is_valid() == expected_valid diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index e9083536..a53c0050 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -212,6 +212,11 @@ class OpportunityEdit(OrganizationUserMemberRoleMixin, UpdateView): def get_success_url(self): return reverse("opportunity:detail", args=(self.request.org.slug, self.object.id)) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org_slug"] = self.request.org.slug + return kwargs + def form_valid(self, form): opportunity = form.instance opportunity.modified_by = self.request.user.email @@ -1079,7 +1084,7 @@ def import_catchment_area(request, org_slug=None, pk=None): @org_member_required def opportunity_user_invite(request, org_slug=None, pk=None): opportunity = get_object_or_404(Opportunity, organization=request.org, id=pk) - form = OpportunityUserInviteForm(data=request.POST or None) + form = OpportunityUserInviteForm(data=request.POST or None, org_slug=request.org.slug) if form.is_valid(): users = form.cleaned_data["users"] filter_country = form.cleaned_data["filter_country"] diff --git a/commcare_connect/organization/views.py b/commcare_connect/organization/views.py index f2e5da30..fce5002c 100644 --- a/commcare_connect/organization/views.py +++ b/commcare_connect/organization/views.py @@ -47,7 +47,7 @@ def organization_home(request, org_slug): if not form: form = OrganizationChangeForm(instance=org) - credentials = connect_id_client.fetch_credentials() + credentials = connect_id_client.fetch_credentials(org_slug=request.org.slug) credential_name = f"Worked for {org.name}" if not any(c.name == credential_name for c in credentials): credentials.append(Credential(name=credential_name, slug=slugify(credential_name))) @@ -96,7 +96,7 @@ def accept_invite(request, org_slug, invite_id): @require_POST def add_credential_view(request, org_slug): org = get_object_or_404(Organization, slug=org_slug) - credentials = connect_id_client.fetch_credentials() + credentials = connect_id_client.fetch_credentials(org_slug=request.org.slug) credential_name = f"Worked for {org.name}" if not any(c.name == credential_name for c in credentials): credentials.append(Credential(name=credential_name, slug=slugify(credential_name))) From 1187920419427ef169991bd24fe2107b22b968c1 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Fri, 8 Nov 2024 09:58:05 -0500 Subject: [PATCH 02/44] allow search by username --- commcare_connect/opportunity/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/commcare_connect/opportunity/admin.py b/commcare_connect/opportunity/admin.py index 6bb084a5..86839d6e 100644 --- a/commcare_connect/opportunity/admin.py +++ b/commcare_connect/opportunity/admin.py @@ -48,6 +48,7 @@ class OpportunityAccessAdmin(admin.ModelAdmin): form = OpportunityAccessCreationForm list_display = ["get_opp_name", "get_username"] actions = ["clear_user_progress"] + search_fields = ["user__username"] @admin.display(description="Opportunity Name") def get_opp_name(self, obj): From 3068d294f18cce836864f14918abbea2e5e809fd Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 14:44:36 +0200 Subject: [PATCH 03/44] change center to africa --- commcare_connect/templates/reports/dashboard.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index e1973c7d..75de2f19 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -143,8 +143,8 @@

Service Delivery Map

container: 'map', // Choose from Mapbox's core styles, or make your own style with Mapbox Studio style: 'mapbox://styles/mapbox/dark-v11', - center: [-103.5917, 40.6699], - zoom: 3 + center: [20, 0], // Centered on Africa (roughly central coordinates) + zoom: 3, }); map.on('load', () => { From fbbafd14bb1f257999b5e0a5f073fbdd660f4991 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 14:44:52 +0200 Subject: [PATCH 04/44] add ability to enable/disable clustering by url param --- commcare_connect/reports/views.py | 3 ++- commcare_connect/templates/reports/dashboard.html | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index c91e8789..6c602801 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -223,12 +223,13 @@ class Meta: @user_passes_test(lambda u: u.is_superuser) def program_dashboard_report(request): filterset = DashboardFilters(request.GET) - + cluster = request.GET.get("cluster", "true") == "true" return render( request, "reports/dashboard.html", context={ "mapbox_token": settings.MAPBOX_TOKEN, + "cluster_visits": cluster, "filter": filterset, }, ) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 75de2f19..89807352 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -139,9 +139,9 @@

Service Delivery Map

window.addEventListener('DOMContentLoaded', () => { mapboxgl.accessToken = "{{ mapbox_token }}"; + const cluster = {% if cluster_visits %}true{% else %}false{% endif %}; map = new mapboxgl.Map({ container: 'map', - // Choose from Mapbox's core styles, or make your own style with Mapbox Studio style: 'mapbox://styles/mapbox/dark-v11', center: [20, 0], // Centered on Africa (roughly central coordinates) zoom: 3, @@ -156,8 +156,8 @@

Service Delivery Map

map.addSource('visits', { type: 'geojson', data: `{% url "reports:visit_map_data" %}?${queryString}`, - cluster: true, - clusterMaxZoom: 14, + cluster: cluster, + clusterMaxZoom: 12, clusterRadius: 50 }); From 1d06e14535c468cc6cf7158a9d12592cc200a934 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 15:19:55 +0200 Subject: [PATCH 05/44] wip: switch to chart clusters --- .../templates/reports/dashboard.html | 208 +++++++++++++++--- 1 file changed, 176 insertions(+), 32 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 89807352..f133fde2 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -147,6 +147,17 @@

Service Delivery Map

zoom: 3, }); + // filters for classifying visits by status + const approved = ['==', ['get', 'status'], 'approved']; + const pending = ['all', + ['!=', ['get', 'status'], 'approved'], + ['!=', ['get', 'status'], 'rejected'] + ]; + const rejected = ['==', ['get', 'status'], 'rejected']; + + // colors to use for the categories + const colors = ['#00FF00', '#FFFF00', '#FF0000']; + map.on('load', () => { // Modify the source configuration to include initial filters const formElement = document.querySelector('#filterForm form'); @@ -158,43 +169,106 @@

Service Delivery Map

data: `{% url "reports:visit_map_data" %}?${queryString}`, cluster: cluster, clusterMaxZoom: 12, - clusterRadius: 50 + clusterRadius: 80, + clusterProperties: { + // keep separate counts for each status category in a cluster + 'approved': ['+', ['case', approved, 1, 0]], + 'pending': ['+', ['case', pending, 1, 0]], + 'rejected': ['+', ['case', rejected, 1, 0]] + } }); + // circle and symbol layers for rendering individual visits (unclustered points) + map.addLayer({ + 'id': 'visit_circle', + 'type': 'circle', + 'source': 'visits', + 'filter': ['!=', 'cluster', true], + 'paint': { + 'circle-color': [ + 'case', + approved, + colors[0], + pending, + colors[1], + rejected, + colors[2], + colors[2] + ], + 'circle-opacity': 0.6, + 'circle-radius': 12 + } + }); + + + // objects for caching and keeping track of HTML marker objects (for performance) + const markers = {}; + let markersOnScreen = {}; + function updateMarkers() { + console.log('updateMarkers'); + 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; + console.log('cluster', feature); + const id = props.cluster_id; + + let marker = markers[id]; + if (!marker) { + const el = createDonutChart(props); + 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; + } const clusterStep1 = 50; const clusterStep2 = 100; - map.addLayer({ - id: 'clusters', - type: 'circle', - source: 'visits', - filter: ['has', 'point_count'], - paint: { - // Use step expressions (https://docs.mapbox.com/style-spec/reference/expressions/#step) - // with three steps to implement three types of circles: - // * Blue, 20px circles when point count is less than clusterStep1 - // * Yellow, 30px circles when point count is between clusterStep1 and clusterStep2 - // * Green, 40px circles when point count is greater than or equal to clusterStep2 - 'circle-color': [ - 'step', - ['get', 'point_count'], - '#51bbd6', - clusterStep1, - '#f1f075', - clusterStep2, - '#00FF00' - ], - 'circle-radius': [ - 'step', - ['get', 'point_count'], - 20, - clusterStep1, - 30, - clusterStep2, - 40 - ] - } - }); + // map.addlayer({ + // id: 'clusters', + // type: 'circle', + // source: 'visits', + // filter: ['has', 'point_count'], + // paint: { + // // use step expressions (https://docs.mapbox.com/style-spec/reference/expressions/#step) + // // with three steps to implement three types of circles: + // // * blue, 20px circles when point count is less than clusterstep1 + // // * yellow, 30px circles when point count is between clusterstep1 and clusterstep2 + // // * green, 40px circles when point count is greater than or equal to clusterstep2 + // 'circle-color': [ + // 'step', + // ['get', 'point_count'], + // '#51bbd6', + // clusterstep1, + // '#f1f075', + // clusterstep2, + // '#00ff00' + // ], + // 'circle-radius': [ + // 'step', + // ['get', 'point_count'], + // 20, + // clusterstep1, + // 30, + // clusterstep2, + // 40 + // ] + // } + // }); map.addLayer({ id: 'cluster-count', @@ -274,7 +348,77 @@

Service Delivery Map

map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = ''; }); + // after the GeoJSON data is loaded, update markers on the screen on every frame + map.on('render', () => { + if (!map.isSourceLoaded('visits')) return; + updateMarkers(); + }); }); + + // code for creating an SVG donut chart from feature properties + function createDonutChart(props) { + 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.6); + const w = r * 2; + + let html = `
+ `; + + for (let i = 0; i < counts.length; i++) { + html += donutSegment( + offsets[i] / total, + (offsets[i] + counts[i]) / total, + r, + r0, + colors[i] + ); + } + html += ` + + ${total.toLocaleString()} + + +
`; + + const el = document.createElement('div'); + el.innerHTML = html; + return el.firstChild; + } + + 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 ``; + } }); + {% endblock %} From 7720e2fbcfe4af14af8b59c5731c9c816f7e0da6 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 15:52:08 +0200 Subject: [PATCH 06/44] cleanup --- .../templates/reports/dashboard.html | 162 ++++++------------ 1 file changed, 55 insertions(+), 107 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index f133fde2..e41e3bc6 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -178,110 +178,6 @@

Service Delivery Map

} }); - // circle and symbol layers for rendering individual visits (unclustered points) - map.addLayer({ - 'id': 'visit_circle', - 'type': 'circle', - 'source': 'visits', - 'filter': ['!=', 'cluster', true], - 'paint': { - 'circle-color': [ - 'case', - approved, - colors[0], - pending, - colors[1], - rejected, - colors[2], - colors[2] - ], - 'circle-opacity': 0.6, - 'circle-radius': 12 - } - }); - - - // objects for caching and keeping track of HTML marker objects (for performance) - const markers = {}; - let markersOnScreen = {}; - function updateMarkers() { - console.log('updateMarkers'); - 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; - console.log('cluster', feature); - const id = props.cluster_id; - - let marker = markers[id]; - if (!marker) { - const el = createDonutChart(props); - 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; - } - const clusterStep1 = 50; - const clusterStep2 = 100; - - // map.addlayer({ - // id: 'clusters', - // type: 'circle', - // source: 'visits', - // filter: ['has', 'point_count'], - // paint: { - // // use step expressions (https://docs.mapbox.com/style-spec/reference/expressions/#step) - // // with three steps to implement three types of circles: - // // * blue, 20px circles when point count is less than clusterstep1 - // // * yellow, 30px circles when point count is between clusterstep1 and clusterstep2 - // // * green, 40px circles when point count is greater than or equal to clusterstep2 - // 'circle-color': [ - // 'step', - // ['get', 'point_count'], - // '#51bbd6', - // clusterstep1, - // '#f1f075', - // clusterstep2, - // '#00ff00' - // ], - // 'circle-radius': [ - // 'step', - // ['get', 'point_count'], - // 20, - // clusterstep1, - // 30, - // clusterstep2, - // 40 - // ] - // } - // }); - - map.addLayer({ - id: 'cluster-count', - type: 'symbol', - source: 'visits', - filter: ['has', 'point_count'], - layout: { - 'text-field': ['get', 'point_count_abbreviated'], - 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], - 'text-size': 12 - } - }); - map.addLayer({ id: 'unclustered-point', type: 'circle', @@ -349,6 +245,39 @@

Service Delivery Map

map.getCanvas().style.cursor = ''; }); // 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() { + 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); + 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; + } + map.on('render', () => { if (!map.isSourceLoaded('visits')) return; updateMarkers(); @@ -372,7 +301,7 @@

Service Delivery Map

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.6); + const r0 = Math.round(r * 0.8); const w = r * 2; let html = `
@@ -387,8 +316,8 @@

Service Delivery Map

colors[i] ); } - html += ` - + html += ` + ${total.toLocaleString()} @@ -396,6 +325,25 @@

Service Delivery Map

const el = document.createElement('div'); el.innerHTML = html; + + // Add mouse events to the donut chart + el.style.cursor = 'pointer'; + + // Add click handler to zoom to cluster + el.addEventListener('click', () => { + map.getSource('visits').getClusterExpansionZoom( + props.cluster_id, + (err, zoom) => { + if (err) return; + + map.easeTo({ + center: props.coordinates, + zoom: zoom + }); + } + ); + }); + return el.firstChild; } From 6ee83a53de240634972ae8a5558a9b8737a1f720 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 15:59:23 +0200 Subject: [PATCH 07/44] cleanup --- .../templates/reports/dashboard.html | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index e41e3bc6..16226039 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -238,12 +238,6 @@

Service Delivery Map

.addTo(map); }); - map.on('mouseenter', 'clusters', () => { - map.getCanvas().style.cursor = 'pointer'; - }); - map.on('mouseleave', 'clusters', () => { - map.getCanvas().style.cursor = ''; - }); // 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 = {}; @@ -262,10 +256,14 @@

Service Delivery Map

let marker = markers[id]; if (!marker) { - const el = createDonutChart(props); - marker = markers[id] = new mapboxgl.Marker({ - element: el - }).setLngLat(coords); + const el = createDonutChart({ + ...props, + cluster_id: id, // Make sure cluster_id is passed + coordinates: coords // Pass the coordinates + }); + marker = markers[id] = new mapboxgl.Marker({ + element: el + }).setLngLat(coords); } newMarkers[id] = marker; @@ -325,12 +323,10 @@

Service Delivery Map

const el = document.createElement('div'); el.innerHTML = html; - - // Add mouse events to the donut chart el.style.cursor = 'pointer'; - // Add click handler to zoom to cluster - el.addEventListener('click', () => { + // Click handler to zoom and navigate to the cluster + el.addEventListener('click', (e) => { map.getSource('visits').getClusterExpansionZoom( props.cluster_id, (err, zoom) => { @@ -344,7 +340,7 @@

Service Delivery Map

); }); - return el.firstChild; + return el; } function donutSegment(start, end, r, r0, color) { From 1bbdd8074053a642e10d667a4712049f29d68f5b Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 16:00:48 +0200 Subject: [PATCH 08/44] delete unused click handler --- .../templates/reports/dashboard.html | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 16226039..01ec3aea 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -191,25 +191,6 @@

Service Delivery Map

} }); - // inspect a cluster on click - map.on('click', 'clusters', (e) => { - const features = map.queryRenderedFeatures(e.point, { - layers: ['clusters'] - }); - const clusterId = features[0].properties.cluster_id; - map.getSource('visits').getClusterExpansionZoom( - clusterId, - (err, zoom) => { - if (err) return; - - map.easeTo({ - center: features[0].geometry.coordinates, - zoom: zoom - }); - } - ); - }); - // When a click event occurs on a feature in // the unclustered-point layer, open a popup at // the location of the feature, with From 3a57fe5b7bc85129311edf288bef133cab8fcaf6 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 16:03:37 +0200 Subject: [PATCH 09/44] format file --- .../templates/reports/dashboard.html | 341 +++++++++--------- 1 file changed, 169 insertions(+), 172 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 01ec3aea..9273d8ab 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -5,7 +5,7 @@ {% block title %}Admin Dashboard{% endblock %} {% block content %}

Program Dashboard

-
+
{% crispy filter.form %} @@ -140,209 +140,206 @@

Service Delivery Map

window.addEventListener('DOMContentLoaded', () => { mapboxgl.accessToken = "{{ mapbox_token }}"; const cluster = {% if cluster_visits %}true{% else %}false{% endif %}; - map = new mapboxgl.Map({ - container: 'map', - style: 'mapbox://styles/mapbox/dark-v11', - center: [20, 0], // Centered on Africa (roughly central coordinates) - zoom: 3, - }); + map = new mapboxgl.Map({ + container: 'map', + style: 'mapbox://styles/mapbox/dark-v11', + center: [20, 0], // Centered on Africa (roughly central coordinates) + zoom: 3, + }); - // filters for classifying visits by status - const approved = ['==', ['get', 'status'], 'approved']; - const pending = ['all', - ['!=', ['get', 'status'], 'approved'], - ['!=', ['get', 'status'], 'rejected'] - ]; - const rejected = ['==', ['get', 'status'], 'rejected']; + // filters for classifying visits by status + const approved = ['==', ['get', 'status'], 'approved']; + const pending = ['all', + ['!=', ['get', 'status'], 'approved'], + ['!=', ['get', 'status'], 'rejected'] + ]; + const rejected = ['==', ['get', 'status'], 'rejected']; - // colors to use for the categories - const colors = ['#00FF00', '#FFFF00', '#FF0000']; + // colors to use for the categories + const colors = ['#00FF00', '#FFFF00', '#FF0000']; - map.on('load', () => { - // Modify the source configuration to include initial filters - const formElement = document.querySelector('#filterForm form'); - const formData = new FormData(formElement); - const queryString = new URLSearchParams(formData).toString(); + map.on('load', () => { + // Modify the source configuration to include initial filters + const formElement = document.querySelector('#filterForm form'); + const formData = new FormData(formElement); + const queryString = new URLSearchParams(formData).toString(); - map.addSource('visits', { - type: 'geojson', - data: `{% url "reports:visit_map_data" %}?${queryString}`, - cluster: cluster, - clusterMaxZoom: 12, - clusterRadius: 80, - clusterProperties: { - // keep separate counts for each status category in a cluster - 'approved': ['+', ['case', approved, 1, 0]], - 'pending': ['+', ['case', pending, 1, 0]], - 'rejected': ['+', ['case', rejected, 1, 0]] - } - }); + map.addSource('visits', { + type: 'geojson', + data: `{% url "reports:visit_map_data" %}?${queryString}`, + cluster: cluster, + clusterMaxZoom: 12, + clusterRadius: 80, + clusterProperties: { + // keep separate counts for each status category in a cluster + 'approved': ['+', ['case', approved, 1, 0]], + 'pending': ['+', ['case', pending, 1, 0]], + 'rejected': ['+', ['case', rejected, 1, 0]] + } + }); - map.addLayer({ - id: 'unclustered-point', - type: 'circle', - source: 'visits', - filter: ['!', ['has', 'point_count']], - paint: { - 'circle-color': ['get', 'color'], - 'circle-radius': 4, - 'circle-stroke-width': 1, - 'circle-stroke-color': '#fff' - } - }); + map.addLayer({ + id: 'unclustered-point', + type: 'circle', + source: 'visits', + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-color': ['get', 'color'], + 'circle-radius': 4, + 'circle-stroke-width': 1, + 'circle-stroke-color': '#fff' + } + }); - // When a click event occurs on a feature in - // the unclustered-point layer, open a popup at - // the location of the feature, with - // description HTML from its properties. - map.on('click', 'unclustered-point', (e) => { - const coordinates = e.features[0].geometry.coordinates.slice(); - const status = e.features[0].properties.status; - const rawDate = e.features[0].properties.visit_date; - const visitDate = new Date(rawDate).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + // When a click event occurs on a feature in + // the unclustered-point layer, open a popup at + // the location of the feature, with + // description HTML from its properties. + map.on('click', 'unclustered-point', (e) => { + const coordinates = e.features[0].geometry.coordinates.slice(); + const status = e.features[0].properties.status; + const rawDate = e.features[0].properties.visit_date; + const visitDate = new Date(rawDate).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - // Ensure that if the map is zoomed out such that - // multiple copies of the feature are visible, the - // popup appears over the copy being pointed to. - if (['mercator', 'equirectangular'].includes(map.getProjection().name)) { - while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { - coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; - } + // Ensure that if the map is zoomed out such that + // multiple copies of the feature are visible, the + // popup appears over the copy being pointed to. + if (['mercator', 'equirectangular'].includes(map.getProjection().name)) { + while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; } + } - new mapboxgl.Popup() - .setLngLat(coordinates) - .setHTML( - `Visit Date: ${visitDate}
Status: ${status}` - ) - .addTo(map); - }); - - // 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() { - const newMarkers = {}; - const features = map.querySourceFeatures('visits'); + new mapboxgl.Popup() + .setLngLat(coordinates) + .setHTML( + `Visit Date: ${visitDate}
Status: ${status}` + ) + .addTo(map); + }); - // 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; + // 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() { + const newMarkers = {}; + const features = map.querySourceFeatures('visits'); - let marker = markers[id]; - if (!marker) { - const el = createDonutChart({ - ...props, - cluster_id: id, // Make sure cluster_id is passed - coordinates: coords // Pass the coordinates - }); - marker = markers[id] = new mapboxgl.Marker({ - element: el - }).setLngLat(coords); - } - newMarkers[id] = marker; + // 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; - 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(); + let marker = markers[id]; + if (!marker) { + const el = createDonutChart({ + ...props, + cluster_id: id, // Make sure cluster_id is passed + coordinates: coords // Pass the coordinates + }); + marker = markers[id] = new mapboxgl.Marker({ + element: el + }).setLngLat(coords); } - markersOnScreen = newMarkers; + 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; + } - map.on('render', () => { - if (!map.isSourceLoaded('visits')) return; - updateMarkers(); - }); + map.on('render', () => { + if (!map.isSourceLoaded('visits')) return; + updateMarkers(); }); + }); - // code for creating an SVG donut chart from feature properties - function createDonutChart(props) { - 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; + // code for creating an SVG donut chart from feature properties + function createDonutChart(props) { + 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 = `
+ let html = `
`; - for (let i = 0; i < counts.length; i++) { - html += donutSegment( - offsets[i] / total, - (offsets[i] + counts[i]) / total, - r, - r0, - colors[i] - ); - } - html += ` + for (let i = 0; i < counts.length; i++) { + html += donutSegment( + offsets[i] / total, + (offsets[i] + counts[i]) / total, + r, + r0, + colors[i] + ); + } + html += ` ${total.toLocaleString()}
`; - const el = document.createElement('div'); - el.innerHTML = html; - el.style.cursor = 'pointer'; + 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; + // 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 - }); - } - ); - }); + map.easeTo({ + center: props.coordinates, + zoom: zoom + }); + } + ); + }); - return el; - } + return el; + } - 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; + 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 ``; - } + // draw an SVG path + return ``; + } }); From 9ccadbbad2f69830910bb3fca569b48a61d561b8 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 16:07:07 +0200 Subject: [PATCH 10/44] wip: loading state --- .../templates/reports/dashboard.html | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 9273d8ab..26d1b1b5 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -73,7 +73,14 @@

Program Dashboard

Service Delivery Map

-
+
+
+
+
+ Loading map... +
+
+
{% endblock content %} @@ -124,17 +131,23 @@

Service Delivery Map

From cb1d7291bce64f34767bba6df002ee8a2777a61f Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 16:52:35 +0200 Subject: [PATCH 16/44] consistent colors --- commcare_connect/reports/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index 7c0305b0..9a8dc980 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -257,8 +257,8 @@ 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 i, result in enumerate(results.all()): location_str = result.get("location_str") @@ -286,7 +286,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) From 08a6f312e1e66e8bd5f455b4433749fd78764152 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 16:52:41 +0200 Subject: [PATCH 17/44] better comments --- .../templates/reports/dashboard.html | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index eddc7a9f..d4b7ee71 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -134,10 +134,9 @@

Service Delivery Map

let map; const mapLoading = document.getElementById('map-loading'); - // Add this function to handle map data refresh window.refreshMapData = async () => { if (!map) return; - console.log('refreshing map data'); + mapLoading.classList.remove('d-none'); // Show loading state const formElement = document.querySelector('#filterForm form'); @@ -145,11 +144,11 @@

Service Delivery Map

const queryString = new URLSearchParams(formData).toString(); try { - // Fetch the data first + // Fetch the data const response = await fetch(`{% url "reports:visit_map_data" %}?${queryString}`); const data = await response.json(); - // Then set it on the map source + // Set it on the map source map.getSource('visits').setData(data); // Hide loading state after data is set @@ -169,6 +168,12 @@

Service Delivery Map

zoom: 3, }); + // This loads a map with two layers, one is a cluster with donut + // charts based on the visit data, inspired heavily by this mapbox example: + // https://docs.mapbox.com/mapbox-gl-js/example/cluster-html/ + // The other is a layer of unclustered points with clickable popups, based on + // this example: https://docs.mapbox.com/mapbox-gl-js/example/popup-on-click/ + // filters for classifying visits by status const approved = ['==', ['get', 'status'], 'approved']; const pending = ['all', @@ -178,7 +183,8 @@

Service Delivery Map

const rejected = ['==', ['get', 'status'], 'rejected']; // colors to use for the categories - const colors = ['#4ade80', '#fbbf24', '#f87171']; // softer green, yellow, red + // soft green, yellow, red + const colors = ['#4ade80', '#fbbf24', '#f87171']; map.on('load', () => { // Modify the source configuration to include initial filters From 6b1868561182e7a0bbe7f2a4d333a518a610e22d Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 16:54:42 +0200 Subject: [PATCH 18/44] default to only 30 days of data --- commcare_connect/reports/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index 9a8dc980..7e25f92b 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -204,7 +204,7 @@ def __init__(self, *args, **kwargs): # Set default dates today = date.today() - default_from = today - timedelta(days=90) + default_from = today - timedelta(days=30) # Set the default values self.data["to_date"] = today.strftime("%Y-%m-%d") From b164959d835e22e803fb2de117fe31d5d4e53e9c Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 16:54:46 +0200 Subject: [PATCH 19/44] whitespace --- commcare_connect/templates/reports/dashboard.html | 1 + 1 file changed, 1 insertion(+) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index d4b7ee71..3a0e1806 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -251,6 +251,7 @@

Service Delivery Map

// objects for caching and keeping track of HTML marker objects (for performance) const markers = {}; let markersOnScreen = {}; + function updateMarkers() { const newMarkers = {}; const features = map.querySourceFeatures('visits'); From 2d8cd8f92225744786bd2fef276940014076d873 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 17:20:26 +0200 Subject: [PATCH 20/44] add minimum cluster size --- commcare_connect/templates/reports/dashboard.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 3a0e1806..7ab9879e 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -132,6 +132,7 @@

Service Delivery Map

+{% endblock %} {% block content %}

Program Dashboard

@@ -183,9 +187,6 @@

Service Delivery Map

]; const rejected = ['==', ['get', 'status'], 'rejected']; - // colors to use for the categories - // soft green, yellow, red - const colors = ['#4ade80', '#fbbf24', '#f87171']; map.on('load', () => { // Modify the source configuration to include initial filters @@ -268,11 +269,12 @@

Service Delivery Map

let marker = markers[id]; if (!marker) { + console.log("creating donut chart for cluster", id) 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); @@ -304,90 +306,6 @@

Service Delivery Map

mapLoading.classList.add('d-none'); // Optionally add error messaging here }); - - // code for creating an SVG donut chart from feature properties - function createDonutChart(props) { - 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 = `
- - - - - - `; - - for (let i = 0; i < counts.length; i++) { - html += donutSegment( - offsets[i] / total, - (offsets[i] + counts[i]) / total, - r, - r0, - colors[i] - ); - } - html += ` - - ${total.toLocaleString()} - - -
`; - - 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 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 ``; - } }); diff --git a/webpack/base.config.js b/webpack/base.config.js index 74235cf8..8f1fe4de 100644 --- a/webpack/base.config.js +++ b/webpack/base.config.js @@ -8,6 +8,10 @@ module.exports = { context: path.join(__dirname, '../'), entry: { project: path.resolve(__dirname, '../commcare_connect/static/js/project'), + dashboard: path.resolve( + __dirname, + '../commcare_connect/static/js/dashboard', + ), vendors: path.resolve(__dirname, '../commcare_connect/static/js/vendors'), }, output: { From be0f47d3d880ba8fa74e726fd3b966aa840f85bc Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Tue, 12 Nov 2024 21:20:42 +0530 Subject: [PATCH 23/44] Publish android assetlinks for deeplinking --- config/urls.py | 3 +++ config/views.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 config/views.py diff --git a/config/urls.py b/config/urls.py index 9eca1758..4c368e0f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,9 +9,12 @@ from commcare_connect.organization.views import organization_create +from . import views + urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"), + path(".well-known/assetlinks.json", views.assetlinks_json, name="assetlinks_json"), # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), diff --git a/config/views.py b/config/views.py new file mode 100644 index 00000000..3b7fc766 --- /dev/null +++ b/config/views.py @@ -0,0 +1,30 @@ +from django.http import JsonResponse + + +def assetlinks_json(request): + assetfile = [ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.commcare.dalvik", + "sha256_cert_fingerprints": + [ + "88:57:18:F8:E8:7D:74:04:97:AE:83:65:74:ED:EF:10:40:D9:4C:E2:54:F0:E0:40:64:77:96:7F:D1:39:F9:81", + "89:55:DF:D8:0E:66:63:06:D2:6D:88:A4:A3:88:A4:D9:16:5A:C4:1A:7E:E1:C6:78:87:00:37:55:93:03:7B:03" + ] + } + }, + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.commcare.dalvik.debug", + "sha256_cert_fingerprints": + [ + "88:57:18:F8:E8:7D:74:04:97:AE:83:65:74:ED:EF:10:40:D9:4C:E2:54:F0:E0:40:64:77:96:7F:D1:39:F9:81" + ] + } + }, + ] + return JsonResponse(assetfile, safe=False) From 4b30441429096705b4d0cbf9af9b430d7448a980 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 17:53:27 +0200 Subject: [PATCH 24/44] extract updateMarkers --- commcare_connect/static/js/dashboard.js | 44 +++++++++++++++++++ .../templates/reports/dashboard.html | 41 +---------------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/commcare_connect/static/js/dashboard.js b/commcare_connect/static/js/dashboard.js index c1d8557f..9318a825 100644 --- a/commcare_connect/static/js/dashboard.js +++ b/commcare_connect/static/js/dashboard.js @@ -4,6 +4,49 @@ console.log('dashboard.js loaded'); // 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) { + console.log('creating donut chart for cluster', id); + 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) { console.log('createDonutChart', props); @@ -87,4 +130,5 @@ function donutSegment(start, end, r, r0, color) { }" fill="${color}" opacity="0.85" stroke="#1f2937" stroke-width="1" />`; } +window.updateMarkers = updateMarkers; window.createDonutChart = createDonutChart; diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 9cc75a1b..2a457188 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -250,49 +250,10 @@

Service Delivery Map

.addTo(map); }); - // 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() { - 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) { - console.log("creating donut chart for cluster", id) - 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; - } map.on('render', () => { if (!map.isSourceLoaded('visits')) return; - updateMarkers(); + updateMarkers(map); }); // Hide loading overlay when initial map load is complete From ee37856a2577a4ee5582dffc455fce3e218f7e14 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 17:53:45 +0200 Subject: [PATCH 25/44] remove logging statments --- commcare_connect/static/js/dashboard.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/commcare_connect/static/js/dashboard.js b/commcare_connect/static/js/dashboard.js index 9318a825..d73535b3 100644 --- a/commcare_connect/static/js/dashboard.js +++ b/commcare_connect/static/js/dashboard.js @@ -23,7 +23,6 @@ function updateMarkers(map) { let marker = markers[id]; if (!marker) { - console.log('creating donut chart for cluster', id); const el = createDonutChart( { ...props, @@ -49,7 +48,6 @@ function updateMarkers(map) { // Function to create a donut chart function createDonutChart(props, map) { - console.log('createDonutChart', props); const offsets = []; const counts = [props.approved, props.pending, props.rejected]; let total = 0; From 8330b101f22176a34a65eab52e380fde680efee7 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 17:56:36 +0200 Subject: [PATCH 26/44] fix tests --- commcare_connect/reports/tests/test_reports.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commcare_connect/reports/tests/test_reports.py b/commcare_connect/reports/tests/test_reports.py index f85ae168..441090f7 100644 --- a/commcare_connect/reports/tests/test_reports.py +++ b/commcare_connect/reports/tests/test_reports.py @@ -106,7 +106,7 @@ def all(self): 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] @@ -115,7 +115,7 @@ def all(self): 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"]) From fa092aa4a52df2beb49600f4e90d196167046e42 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Tue, 12 Nov 2024 17:58:14 +0200 Subject: [PATCH 27/44] put map in a container --- .../templates/reports/dashboard.html | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 2a457188..bafdd0aa 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -74,15 +74,17 @@

Program Dashboard

-
-
-

Service Delivery Map

-
-
-
-
- Loading map... +
+
+
+

Service Delivery Map

+
+
+
+
+ Loading map... +
From f64430ad9d7ff06829b561ddb32ba67b336d7f18 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 14:40:58 +0200 Subject: [PATCH 28/44] dummy chart implementation --- .../templates/reports/dashboard.html | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index bafdd0aa..f919dba1 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -6,6 +6,7 @@ {% block javascript %} {{ block.super }} + {% endblock %} {% block content %}

Program Dashboard

@@ -76,7 +77,7 @@

Program Dashboard

-
+

Service Delivery Map

@@ -88,6 +89,10 @@

Service Delivery Map

+
+

Visits over Time

+ +
{% endblock content %} @@ -172,7 +177,7 @@

Service Delivery Map

container: 'map', style: 'mapbox://styles/mapbox/dark-v11', center: [20, 0], // Centered on Africa (roughly central coordinates) - zoom: 3, + zoom: 2, }); // This loads a map with two layers, one is a cluster with donut @@ -269,6 +274,69 @@

Service Delivery Map

mapLoading.classList.add('d-none'); // Optionally add error messaging here }); + + // Generate last 30 days of dummy data + const generateDummyData = () => { + const data = []; + const labels = []; + const today = new Date(); + + for (let i = 29; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); + // Random number between 10 and 50 + data.push(Math.floor(Math.random() * 40) + 10); + } + return { labels, data }; + }; + + const { labels, data } = generateDummyData(); + + const ctx = document.getElementById('visits-over-time'); + const chart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [{ + label: 'Total Visits', + data: data, + borderColor: 'rgb(75, 192, 192)', + backgroundColor: 'rgba(75, 192, 192, 0.2)', + fill: true, + tension: 0.3 + }] + }, + options: { + responsive: false, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Visits by Date' + }, + tooltip: { + mode: 'index', + intersect: false, + } + }, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: 'Number of Visits' + } + }, + x: { + title: { + display: true, + text: 'Date' + } + } + } + } + }); }); From c9c76c641b80acb002b60c9069f5adbdf02113ea Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 14:47:44 +0200 Subject: [PATCH 29/44] fix sizing, responsiveness --- commcare_connect/templates/reports/dashboard.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index f919dba1..d9586f90 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -91,7 +91,9 @@

Service Delivery Map

Visits over Time

- +
+ +
@@ -288,6 +290,7 @@

Visits over Time

// Random number between 10 and 50 data.push(Math.floor(Math.random() * 40) + 10); } + console.log({labels, data}); return { labels, data }; }; @@ -308,7 +311,7 @@

Visits over Time

}] }, options: { - responsive: false, + responsive: true, maintainAspectRatio: false, plugins: { title: { From aabb39fcb7204d7fef0c1a43003e6fd03ace2c71 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 14:49:24 +0200 Subject: [PATCH 30/44] smaller cluster radius --- commcare_connect/templates/reports/dashboard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index d9586f90..903a49d4 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -208,7 +208,7 @@

Visits over Time

data: `{% url "reports:visit_map_data" %}?${queryString}`, cluster: true, clusterMaxZoom: 14, - clusterRadius: 80, + clusterRadius: 40, clusterMinPoints: minClusterSize, clusterProperties: { // keep separate counts for each status category in a cluster From bcce9ac1a2f71157ec90f5271ff2130d36fd2aaf Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:00:23 +0200 Subject: [PATCH 31/44] add api for graphs --- commcare_connect/reports/urls.py | 1 + commcare_connect/reports/views.py | 41 +++++- .../templates/reports/dashboard.html | 129 ++++++++++-------- 3 files changed, 115 insertions(+), 56 deletions(-) diff --git a/commcare_connect/reports/urls.py b/commcare_connect/reports/urls.py index cd05d994..bb8a9709 100644 --- a/commcare_connect/reports/urls.py +++ b/commcare_connect/reports/urls.py @@ -9,4 +9,5 @@ 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/visits_over_time/", views.visits_over_time_api, name="visits_over_time_api"), ] diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index 7e25f92b..93cb9d27 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -8,7 +8,7 @@ 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.models import Max, Q, Sum +from django.db.models import Count, Max, Q, Sum from django.http import JsonResponse from django.shortcuts import render from django.urls import reverse @@ -418,3 +418,42 @@ def dashboard_stats_api(request): "percent_verified": f"{percent_verified:.1f}%", } ) + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def visits_over_time_api(request): + filterset = DashboardFilters(request.GET) + + # Use the filtered queryset + queryset = UserVisit.objects.all() + + 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) + + # Aggregate visits by date + visits_by_date = queryset.values("visit_date").annotate(count=Count("id")).order_by("visit_date") + + # Create a complete date range with 0s for missing dates + date_counts = {result["visit_date"]: result["count"] for result in visits_by_date} + + data = [] + labels = [] + current_date = from_date + while current_date <= to_date: + labels.append(current_date.strftime("%b %d")) + data.append(date_counts.get(current_date, 0)) + current_date += timedelta(days=1) + + return JsonResponse( + { + "labels": labels, + "data": data, + } + ) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 903a49d4..62de61d3 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -277,68 +277,87 @@

Visits over Time

// Optionally add error messaging here }); - // Generate last 30 days of dummy data - const generateDummyData = () => { - const data = []; - const labels = []; - const today = new Date(); - for (let i = 29; i >= 0; i--) { - const date = new Date(today); - date.setDate(date.getDate() - i); - labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); - // Random number between 10 and 50 - data.push(Math.floor(Math.random() * 40) + 10); - } - console.log({labels, data}); - return { labels, data }; - }; + }); + // Update chart when page loads + window.addEventListener('DOMContentLoaded', () => { + // Replace the chart initialization code with this: + const ctx = document.getElementById('visits-over-time'); + let visitsChart; - const { labels, data } = generateDummyData(); + async function updateVisitsChart() { + console.log('updating visits chart'); + try { + // Get form data for filters + const formElement = document.querySelector('#filterForm form'); + const formData = new FormData(formElement); + const queryString = new URLSearchParams(formData).toString(); - const ctx = document.getElementById('visits-over-time'); - const chart = new Chart(ctx, { - type: 'line', - data: { - labels: labels, - datasets: [{ - label: 'Total Visits', - data: data, - borderColor: 'rgb(75, 192, 192)', - backgroundColor: 'rgba(75, 192, 192, 0.2)', - fill: true, - tension: 0.3 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: true, - text: 'Visits by Date' - }, - tooltip: { - mode: 'index', - intersect: false, - } - }, - scales: { - y: { - beginAtZero: true, - title: { - display: true, - text: 'Number of Visits' - } + // Fetch data from API + const response = await fetch(`{% url 'reports:visits_over_time_api' %}?${queryString}`); + console.log('response', response); + const chartData = await response.json(); + + if (visitsChart) { + visitsChart.destroy(); + } + + visitsChart = new Chart(ctx, { + type: 'line', + data: { + labels: chartData.labels, + datasets: [{ + label: 'Total Visits', + data: chartData.data, + borderColor: 'rgb(75, 192, 192)', + backgroundColor: 'rgba(75, 192, 192, 0.2)', + fill: true, + tension: 0.3 + }] }, - x: { - title: { - display: true, - text: 'Date' + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Visits by Date' + }, + tooltip: { + mode: 'index', + intersect: false, + } + }, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: 'Number of Visits' + } + }, + x: { + title: { + display: true, + text: 'Date' + } + } } } - } + }); + } catch (error) { + console.error('Error updating visits chart:', error); } + } + + + console.log('updating visits chart on load'); + updateVisitsChart(); + + // Update chart when filters change + const formElement = document.querySelector('#filterForm form'); + formElement.querySelectorAll('select, input').forEach(input => { + input.addEventListener('change', updateVisitsChart); }); }); From 1cc46a395b130c15cac96ed37bc695290ddfa452 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:08:57 +0200 Subject: [PATCH 32/44] get program breakdown kinda working --- commcare_connect/reports/views.py | 44 ++++++++++++------- .../templates/reports/dashboard.html | 35 ++++++++++----- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index 93cb9d27..ed197872 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -424,10 +424,8 @@ def dashboard_stats_api(request): @user_passes_test(lambda u: u.is_superuser) def visits_over_time_api(request): filterset = DashboardFilters(request.GET) - - # Use the filtered queryset 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"] @@ -437,23 +435,39 @@ def visits_over_time_api(request): from_date = to_date - timedelta(days=30) queryset = queryset.filter(visit_date__gte=from_date, visit_date__lte=to_date) - # Aggregate visits by date - visits_by_date = queryset.values("visit_date").annotate(count=Count("id")).order_by("visit_date") + # Get visits by date and program in a single query + visits_by_program = ( + queryset.values("visit_date", "opportunity__delivery_type__name") + .annotate(count=Count("id")) + .order_by("visit_date", "opportunity__delivery_type__name") + ) - # Create a complete date range with 0s for missing dates - date_counts = {result["visit_date"]: result["count"] for result in visits_by_date} + # Create lookup dict for program data + program_data = {} + for visit in visits_by_program: + 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"]] = visit["count"] - data = [] + # Create labels and datasets labels = [] + datasets = [] current_date = from_date + + # Build labels array while current_date <= to_date: labels.append(current_date.strftime("%b %d")) - data.append(date_counts.get(current_date, 0)) current_date += timedelta(days=1) - return JsonResponse( - { - "labels": labels, - "data": data, - } - ) + # Build dataset for each program + for program_name in program_data.keys(): + data = [] + current_date = from_date + while current_date <= to_date: + data.append(program_data[program_name].get(current_date, 0)) + current_date += timedelta(days=1) + + datasets.append({"name": program_name, "data": data}) + + return JsonResponse({"labels": labels, "datasets": datasets}) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 62de61d3..4ec0329e 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -286,34 +286,46 @@

Visits over Time

let visitsChart; async function updateVisitsChart() { - console.log('updating visits chart'); try { - // Get form data for filters const formElement = document.querySelector('#filterForm form'); const formData = new FormData(formElement); const queryString = new URLSearchParams(formData).toString(); - // Fetch data from API const response = await fetch(`{% url 'reports:visits_over_time_api' %}?${queryString}`); - console.log('response', response); const chartData = await response.json(); if (visitsChart) { visitsChart.destroy(); } + // Assuming the API now returns data in this format: + // { + // labels: ['2024-01', '2024-02', ...], + // datasets: [ + // { name: 'Program A', data: [10, 20, ...] }, + // { name: 'Program B', data: [15, 25, ...] }, + // ] + // } + + const colors = [ + { border: 'rgb(75, 192, 192)', background: 'rgba(75, 192, 192, 0.2)' }, + { border: 'rgb(255, 99, 132)', background: 'rgba(255, 99, 132, 0.2)' }, + { border: 'rgb(255, 205, 86)', background: 'rgba(255, 205, 86, 0.2)' }, + { border: 'rgb(54, 162, 235)', background: 'rgba(54, 162, 235, 0.2)' }, + ]; + visitsChart = new Chart(ctx, { type: 'line', data: { labels: chartData.labels, - datasets: [{ - label: 'Total Visits', - data: chartData.data, - borderColor: 'rgb(75, 192, 192)', - backgroundColor: 'rgba(75, 192, 192, 0.2)', + datasets: chartData.datasets.map((dataset, index) => ({ + label: dataset.name, + data: dataset.data, + borderColor: colors[index % colors.length].border, + backgroundColor: colors[index % colors.length].background, fill: true, tension: 0.3 - }] + })) }, options: { responsive: true, @@ -321,7 +333,7 @@

Visits over Time

plugins: { title: { display: true, - text: 'Visits by Date' + text: 'Visits by Program' }, tooltip: { mode: 'index', @@ -331,6 +343,7 @@

Visits over Time

scales: { y: { beginAtZero: true, + stacked: true, title: { display: true, text: 'Number of Visits' From 38e0f134716c7c1d363d874b572a04dda9b68989 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:11:23 +0200 Subject: [PATCH 33/44] add "unknown" label --- commcare_connect/reports/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index ed197872..e02d858a 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -468,6 +468,6 @@ def visits_over_time_api(request): data.append(program_data[program_name].get(current_date, 0)) current_date += timedelta(days=1) - datasets.append({"name": program_name, "data": data}) + datasets.append({"name": program_name or "Unknown", "data": data}) return JsonResponse({"labels": labels, "datasets": datasets}) From 19870ab515ec49591f9596aa87ee15163048daf4 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:13:37 +0200 Subject: [PATCH 34/44] improve color overlaps --- commcare_connect/templates/reports/dashboard.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 4ec0329e..22338409 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -308,10 +308,10 @@

Visits over Time

// } const colors = [ - { border: 'rgb(75, 192, 192)', background: 'rgba(75, 192, 192, 0.2)' }, - { border: 'rgb(255, 99, 132)', background: 'rgba(255, 99, 132, 0.2)' }, - { border: 'rgb(255, 205, 86)', background: 'rgba(255, 205, 86, 0.2)' }, - { border: 'rgb(54, 162, 235)', background: 'rgba(54, 162, 235, 0.2)' }, + { border: 'rgb(75, 192, 192)', background: 'rgb(146, 219, 219)' }, + { border: 'rgb(255, 99, 132)', background: 'rgb(255, 178, 193)' }, + { border: 'rgb(255, 205, 86)', background: 'rgb(255, 227, 167)' }, + { border: 'rgb(54, 162, 235)', background: 'rgb(157, 207, 245)' }, ]; visitsChart = new Chart(ctx, { From f5746e54138631e96fbc3878c8cfc05a8874e73e Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:20:13 +0200 Subject: [PATCH 35/44] make it a bar chart --- .../templates/reports/dashboard.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 22338409..3c488b72 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -315,7 +315,7 @@

Visits over Time

]; visitsChart = new Chart(ctx, { - type: 'line', + type: 'bar', data: { labels: chartData.labels, datasets: chartData.datasets.map((dataset, index) => ({ @@ -323,8 +323,7 @@

Visits over Time

data: dataset.data, borderColor: colors[index % colors.length].border, backgroundColor: colors[index % colors.length].background, - fill: true, - tension: 0.3 + borderWidth: 1 })) }, options: { @@ -341,18 +340,19 @@

Visits over Time

} }, scales: { - y: { - beginAtZero: true, + x: { stacked: true, title: { display: true, - text: 'Number of Visits' + text: 'Date' } }, - x: { + y: { + stacked: true, + beginAtZero: true, title: { display: true, - text: 'Date' + text: 'Number of Visits' } } } From ac9ac103a52d39e82d5900213f11bed183be6d8f Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:26:31 +0200 Subject: [PATCH 36/44] mock out spots for pie charts --- .../templates/reports/dashboard.html | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 3c488b72..2b99885d 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -91,9 +91,23 @@

Service Delivery Map

Visits over Time

-
+
+
+
+

Visits by Program

+
+ +
+
+
+

Visits by Status

+
+ +
+
+
From 2eb33e048f241e6c38755e8814716315a41599f1 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:29:05 +0200 Subject: [PATCH 37/44] implement pie charts --- commcare_connect/reports/views.py | 44 ++++-- .../templates/reports/dashboard.html | 133 +++++++++++++----- 2 files changed, 132 insertions(+), 45 deletions(-) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index e02d858a..3439d419 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -435,32 +435,39 @@ def visits_over_time_api(request): from_date = to_date - timedelta(days=30) queryset = queryset.filter(visit_date__gte=from_date, visit_date__lte=to_date) - # Get visits by date and program in a single query - visits_by_program = ( + # Get data for all three charts + # 1. Visits over time by program + visits_by_program_time = ( queryset.values("visit_date", "opportunity__delivery_type__name") .annotate(count=Count("id")) .order_by("visit_date", "opportunity__delivery_type__name") ) - # Create lookup dict for program data + # 2. Total visits by program + visits_by_program = ( + queryset.values("opportunity__delivery_type__name").annotate(count=Count("id")).order_by("-count") + ) + + # 3. Visits by status + visits_by_status = queryset.values("status").annotate(count=Count("id")).order_by("-count") + + # Process time series data program_data = {} - for visit in visits_by_program: + 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"]] = visit["count"] - # Create labels and datasets + # Create labels and datasets for time series labels = [] - datasets = [] + time_datasets = [] current_date = from_date - # Build labels array while current_date <= to_date: labels.append(current_date.strftime("%b %d")) current_date += timedelta(days=1) - # Build dataset for each program for program_name in program_data.keys(): data = [] current_date = from_date @@ -468,6 +475,23 @@ def visits_over_time_api(request): data.append(program_data[program_name].get(current_date, 0)) current_date += timedelta(days=1) - datasets.append({"name": program_name or "Unknown", "data": data}) + time_datasets.append({"name": program_name or "Unknown", "data": data}) + + # Process pie chart data + program_pie_data = { + "labels": [item["opportunity__delivery_type__name"] or "Unknown" for item in visits_by_program], + "data": [item["count"] for item in visits_by_program], + } - return JsonResponse({"labels": labels, "datasets": datasets}) + status_pie_data = { + "labels": [item["status"] or "Unknown" for item in visits_by_status], + "data": [item["count"] for item in visits_by_status], + } + + return JsonResponse( + { + "time_series": {"labels": labels, "datasets": time_datasets}, + "program_pie": program_pie_data, + "status_pie": status_pie_data, + } + ) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 2b99885d..ee8e8f13 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -295,48 +295,42 @@

Visits by Status

}); // Update chart when page loads window.addEventListener('DOMContentLoaded', () => { - // Replace the chart initialization code with this: - const ctx = document.getElementById('visits-over-time'); - let visitsChart; + const timeSeriesCtx = document.getElementById('visits-over-time'); + const programPieCtx = document.getElementById('visits-by-program'); + const statusPieCtx = document.getElementById('visits-by-status'); - async function updateVisitsChart() { + let timeSeriesChart, programPieChart, statusPieChart; + + 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)' }, + ]; + + async function updateCharts() { try { const formElement = document.querySelector('#filterForm form'); const formData = new FormData(formElement); const queryString = new URLSearchParams(formData).toString(); const response = await fetch(`{% url 'reports:visits_over_time_api' %}?${queryString}`); - const chartData = await response.json(); + const data = await response.json(); - if (visitsChart) { - visitsChart.destroy(); + // Update time series chart + if (timeSeriesChart) { + timeSeriesChart.destroy(); } - // Assuming the API now returns data in this format: - // { - // labels: ['2024-01', '2024-02', ...], - // datasets: [ - // { name: 'Program A', data: [10, 20, ...] }, - // { name: 'Program B', data: [15, 25, ...] }, - // ] - // } - - const colors = [ - { border: 'rgb(75, 192, 192)', background: 'rgb(146, 219, 219)' }, - { border: 'rgb(255, 99, 132)', background: 'rgb(255, 178, 193)' }, - { border: 'rgb(255, 205, 86)', background: 'rgb(255, 227, 167)' }, - { border: 'rgb(54, 162, 235)', background: 'rgb(157, 207, 245)' }, - ]; - - visitsChart = new Chart(ctx, { + timeSeriesChart = new Chart(timeSeriesCtx, { type: 'bar', data: { - labels: chartData.labels, - datasets: chartData.datasets.map((dataset, index) => ({ + labels: data.time_series.labels, + datasets: data.time_series.datasets.map((dataset, index) => ({ label: dataset.name, data: dataset.data, - borderColor: colors[index % colors.length].border, - backgroundColor: colors[index % colors.length].background, + borderColor: chartColors[index % chartColors.length].border, + backgroundColor: chartColors[index % chartColors.length].background, borderWidth: 1 })) }, @@ -346,7 +340,7 @@

Visits by Status

plugins: { title: { display: true, - text: 'Visits by Program' + text: 'Visits Over Time' }, tooltip: { mode: 'index', @@ -372,19 +366,88 @@

Visits by Status

} } }); + + // Update program pie chart + if (programPieChart) { + programPieChart.destroy(); + } + + programPieChart = new Chart(programPieCtx, { + type: 'pie', + data: { + labels: data.program_pie.labels, + datasets: [{ + data: data.program_pie.data, + backgroundColor: chartColors.map(c => c.background), + borderColor: chartColors.map(c => c.border), + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + boxWidth: 12 + } + } + } + } + }); + + // Update status pie chart + if (statusPieChart) { + statusPieChart.destroy(); + } + + 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)' } + }; + + statusPieChart = new Chart(statusPieCtx, { + type: 'pie', + data: { + labels: data.status_pie.labels, + datasets: [{ + data: data.status_pie.data, + backgroundColor: data.status_pie.labels.map(status => + statusColors[status]?.background || 'rgba(156, 163, 175, 0.8)' + ), + borderColor: data.status_pie.labels.map(status => + statusColors[status]?.border || 'rgb(156, 163, 175)' + ), + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + boxWidth: 12 + } + } + } + } + }); + } catch (error) { - console.error('Error updating visits chart:', error); + console.error('Error updating charts:', error); } } + updateCharts(); - console.log('updating visits chart on load'); - updateVisitsChart(); - - // Update chart when filters change + // Update charts when filters change const formElement = document.querySelector('#filterForm form'); formElement.querySelectorAll('select, input').forEach(input => { - input.addEventListener('change', updateVisitsChart); + input.addEventListener('change', updateCharts); }); }); From 14be29bf8f2341584336e77bbc514a4d54eac229 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:35:58 +0200 Subject: [PATCH 38/44] style tweaks --- .../templates/reports/dashboard.html | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index ee8e8f13..5874858c 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -90,24 +90,26 @@

Service Delivery Map

-

Visits over Time

-
- -
-
+

Visit Breakdown

+
-

Visits by Program

+
By Program
-

Visits by Status

+
By Status
+
Over time
+
+ +
+
@@ -338,10 +340,6 @@

Visits by Status

responsive: true, maintainAspectRatio: false, plugins: { - title: { - display: true, - text: 'Visits Over Time' - }, tooltip: { mode: 'index', intersect: false, @@ -388,7 +386,7 @@

Visits by Status

maintainAspectRatio: false, plugins: { legend: { - position: 'right', + position: 'bottom', labels: { boxWidth: 12 } @@ -428,7 +426,7 @@

Visits by Status

maintainAspectRatio: false, plugins: { legend: { - position: 'right', + position: 'bottom', labels: { boxWidth: 12 } From 6748eaec1bb7786240d504033f895d020b61b8f6 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:38:56 +0200 Subject: [PATCH 39/44] use a better name --- commcare_connect/reports/urls.py | 2 +- commcare_connect/reports/views.py | 2 +- commcare_connect/templates/reports/dashboard.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/commcare_connect/reports/urls.py b/commcare_connect/reports/urls.py index bb8a9709..de9ca690 100644 --- a/commcare_connect/reports/urls.py +++ b/commcare_connect/reports/urls.py @@ -9,5 +9,5 @@ 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/visits_over_time/", views.visits_over_time_api, name="visits_over_time_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 3439d419..e42c9805 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -422,7 +422,7 @@ def dashboard_stats_api(request): @login_required @user_passes_test(lambda u: u.is_superuser) -def visits_over_time_api(request): +def dashboard_charts_api(request): filterset = DashboardFilters(request.GET) queryset = UserVisit.objects.all() # Use the filtered queryset if available, else use last 30 days diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 5874858c..1b08d494 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -316,7 +316,7 @@
Over time
const formData = new FormData(formElement); const queryString = new URLSearchParams(formData).toString(); - const response = await fetch(`{% url 'reports:visits_over_time_api' %}?${queryString}`); + const response = await fetch(`{% url 'reports:dashboard_charts_api' %}?${queryString}`); const data = await response.json(); // Update time series chart From c02a374cf394aa0f4e200eef9baa48e7c096760b Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:42:14 +0200 Subject: [PATCH 40/44] refactor each chart to its own function --- commcare_connect/reports/views.py | 44 ++++++++++++++++--------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index e42c9805..623fdc33 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -435,22 +435,23 @@ def dashboard_charts_api(request): from_date = to_date - timedelta(days=30) queryset = queryset.filter(visit_date__gte=from_date, visit_date__lte=to_date) - # Get data for all three charts - # 1. Visits over time by program + 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): + # Get visits over time by program visits_by_program_time = ( queryset.values("visit_date", "opportunity__delivery_type__name") .annotate(count=Count("id")) .order_by("visit_date", "opportunity__delivery_type__name") ) - # 2. Total visits by program - visits_by_program = ( - queryset.values("opportunity__delivery_type__name").annotate(count=Count("id")).order_by("-count") - ) - - # 3. Visits by status - visits_by_status = queryset.values("status").annotate(count=Count("id")).order_by("-count") - # Process time series data program_data = {} for visit in visits_by_program_time: @@ -477,21 +478,22 @@ def dashboard_charts_api(request): time_datasets.append({"name": program_name or "Unknown", "data": data}) - # Process pie chart data - program_pie_data = { + return {"labels": labels, "datasets": time_datasets} + + +def _get_program_pie_data(queryset): + 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], } - status_pie_data = { + +def _get_status_pie_data(queryset): + 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], } - - return JsonResponse( - { - "time_series": {"labels": labels, "datasets": time_datasets}, - "program_pie": program_pie_data, - "status_pie": status_pie_data, - } - ) From 684143e416ad09134eeff799c5f7c0b168ddd961 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:45:02 +0200 Subject: [PATCH 41/44] add doc strings --- commcare_connect/reports/views.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index 623fdc33..47e105b1 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -445,6 +445,21 @@ def dashboard_charts_api(request): 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("visit_date", "opportunity__delivery_type__name") @@ -482,6 +497,12 @@ def _get_time_series_data(queryset, from_date, to_date): 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") ) @@ -492,6 +513,12 @@ def _get_program_pie_data(queryset): 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], From fb11e70fb43141d850b668b5ec91e0a3329ad64e Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 15:59:42 +0200 Subject: [PATCH 42/44] externalize js to js file --- commcare_connect/static/js/dashboard.js | 131 ++++++++++++++++++ .../templates/reports/dashboard.html | 111 +-------------- 2 files changed, 134 insertions(+), 108 deletions(-) diff --git a/commcare_connect/static/js/dashboard.js b/commcare_connect/static/js/dashboard.js index d73535b3..0a761c6a 100644 --- a/commcare_connect/static/js/dashboard.js +++ b/commcare_connect/static/js/dashboard.js @@ -128,5 +128,136 @@ function donutSegment(start, end, r, r0, color) { }" fill="${color}" opacity="0.85" stroke="#1f2937" stroke-width="1" />`; } +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) { + 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) { + 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/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 1b08d494..5fe5a5b2 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -303,13 +303,6 @@
Over time
let timeSeriesChart, programPieChart, statusPieChart; - 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)' }, - ]; - async function updateCharts() { try { const formElement = document.querySelector('#filterForm form'); @@ -323,117 +316,19 @@
Over time
if (timeSeriesChart) { timeSeriesChart.destroy(); } - - timeSeriesChart = new Chart(timeSeriesCtx, { - type: 'bar', - data: { - labels: data.time_series.labels, - datasets: data.time_series.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' - } - } - } - } - }); + timeSeriesChart = createTimeSeriesChart(timeSeriesCtx, data.time_series); // Update program pie chart if (programPieChart) { programPieChart.destroy(); } - - programPieChart = new Chart(programPieCtx, { - type: 'pie', - data: { - labels: data.program_pie.labels, - datasets: [{ - data: data.program_pie.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 - } - } - } - } - }); + programPieChart = createProgramPieChart(programPieCtx, data.program_pie); // Update status pie chart if (statusPieChart) { statusPieChart.destroy(); } - - 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)' } - }; - - statusPieChart = new Chart(statusPieCtx, { - type: 'pie', - data: { - labels: data.status_pie.labels, - datasets: [{ - data: data.status_pie.data, - backgroundColor: data.status_pie.labels.map(status => - statusColors[status]?.background || 'rgba(156, 163, 175, 0.8)' - ), - borderColor: data.status_pie.labels.map(status => - statusColors[status]?.border || 'rgb(156, 163, 175)' - ), - borderWidth: 1 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'bottom', - labels: { - boxWidth: 12 - } - } - } - } - }); + statusPieChart = createStatusPieChart(statusPieCtx, data.status_pie); } catch (error) { console.error('Error updating charts:', error); From 9a426cee9821cdd463445959aae484ed05a244a2 Mon Sep 17 00:00:00 2001 From: Cory Zue Date: Wed, 13 Nov 2024 16:04:10 +0200 Subject: [PATCH 43/44] add better empty states --- commcare_connect/static/js/dashboard.js | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/commcare_connect/static/js/dashboard.js b/commcare_connect/static/js/dashboard.js index 0a761c6a..53ca2b65 100644 --- a/commcare_connect/static/js/dashboard.js +++ b/commcare_connect/static/js/dashboard.js @@ -194,6 +194,37 @@ function createTimeSeriesChart(ctx, data) { } 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: { @@ -223,6 +254,37 @@ function createProgramPieChart(ctx, data) { } 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: { From 1644c3a83944a9e0ad78fe3bbc737eaa66592c2c Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Fri, 15 Nov 2024 17:20:52 +0530 Subject: [PATCH 44/44] Fix linter error --- config/views.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/config/views.py b/config/views.py index 3b7fc766..58135f30 100644 --- a/config/views.py +++ b/config/views.py @@ -8,23 +8,21 @@ def assetlinks_json(request): "target": { "namespace": "android_app", "package_name": "org.commcare.dalvik", - "sha256_cert_fingerprints": - [ + "sha256_cert_fingerprints": [ "88:57:18:F8:E8:7D:74:04:97:AE:83:65:74:ED:EF:10:40:D9:4C:E2:54:F0:E0:40:64:77:96:7F:D1:39:F9:81", - "89:55:DF:D8:0E:66:63:06:D2:6D:88:A4:A3:88:A4:D9:16:5A:C4:1A:7E:E1:C6:78:87:00:37:55:93:03:7B:03" - ] - } + "89:55:DF:D8:0E:66:63:06:D2:6D:88:A4:A3:88:A4:D9:16:5A:C4:1A:7E:E1:C6:78:87:00:37:55:93:03:7B:03", + ], + }, }, { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "org.commcare.dalvik.debug", - "sha256_cert_fingerprints": - [ + "sha256_cert_fingerprints": [ "88:57:18:F8:E8:7D:74:04:97:AE:83:65:74:ED:EF:10:40:D9:4C:E2:54:F0:E0:40:64:77:96:7F:D1:39:F9:81" - ] - } + ], + }, }, ] return JsonResponse(assetfile, safe=False)