From 3da50d3a1dbeefb3a6b56bfdd422ddee846d924e Mon Sep 17 00:00:00 2001 From: Hannah Cushman Garland Date: Tue, 1 Oct 2024 11:44:40 -0500 Subject: [PATCH] Revise contribution/expenditure chart as a true area chart --- .pre-commit-config.yaml | 15 - camp_fin/models.py | 126 +---- camp_fin/static/js/chart_helper.js | 821 +++++++++++++++-------------- 3 files changed, 462 insertions(+), 500 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d231b91..6671882 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,21 +25,6 @@ repos: files: .*/templates/.*\.html$ args: [--tabwidth=2] # tabs should be 2 spaces in Django templates - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.5.1 - hooks: - - id: prettier - files: \.(js|ts|jsx|tsx|css|less|json|markdown|md|yaml|yml)$ - - - repo: https://github.com/pre-commit/mirrors-eslint - rev: "1f7d592" - hooks: - - id: eslint - additional_dependencies: - - eslint@8.19.0 - - eslint-plugin-react@7.30.1 - - eslint-config-prettier@8.5.0 - - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: diff --git a/camp_fin/models.py b/camp_fin/models.py index 92b346d..510c1de 100644 --- a/camp_fin/models.py +++ b/camp_fin/models.py @@ -1,7 +1,6 @@ from collections import namedtuple from datetime import datetime -from dateutil.rrule import MONTHLY, rrule from django.db import connection, models from django.utils import timezone from django.utils.translation import gettext as _ @@ -944,52 +943,6 @@ def trends(self, since="2010"): Generate a dict of filing trends for use in contribution/expenditure charts for this Entity. """ - - def stack_trends(trend): - """ - Private helper method for compiling trends. - """ - stacked_trend = [] - for begin, end, rate in trend: - if not stacked_trend: - stacked_trend.append((rate, begin)) - stacked_trend.append((rate, end)) - - elif begin == stacked_trend[-1][1]: - stacked_trend.append((rate, begin)) - stacked_trend.append((rate, end)) - - elif begin > stacked_trend[-1][1]: - previous_rate, previous_end = stacked_trend[-1] - stacked_trend.append((previous_rate, begin)) - stacked_trend.append((rate, begin)) - stacked_trend.append((rate, end)) - - elif begin < stacked_trend[-1][1]: - previous_rate, previous_end = stacked_trend.pop() - stacked_trend.append((previous_rate, begin)) - stacked_trend.append((rate + previous_rate, begin)) - - if end < previous_end: - stacked_trend.append((rate + previous_rate, end)) - stacked_trend.append((previous_rate, end)) - stacked_trend.append((previous_rate, previous_end)) - - elif end > previous_end: - stacked_trend.append((rate + previous_rate, previous_end)) - stacked_trend.append((rate, previous_end)) - stacked_trend.append((rate, end)) - else: - stacked_trend.append((rate + previous_rate, end)) - - flattened_trend = [] - - for i, point in enumerate(stacked_trend): - rate, date = point - flattened_trend.append([rate, *date]) - - return flattened_trend - # Balances and debts summed_filings = """ SELECT @@ -1065,68 +1018,43 @@ def stack_trends(trend): # Donations and expenditures monthly_query = """ SELECT - {table}.amount AS amount, - {table}.month AS month - FROM {table}_by_month AS {table} - WHERE {table}.entity_id = %s - AND {table}.month >= '{year}-01-01'::date - ORDER BY month + months.year, + months.month, + {table}.amount + FROM ( + SELECT + DISTINCT DATE_PART('year', month) AS year, + GENERATE_SERIES(1, 12) AS month + FROM {table}_by_month + ORDER BY year, month + ) months + LEFT JOIN ( + SELECT + {table}.amount AS amount, + DATE_PART('month', {table}.month) AS month, + DATE_PART('year', {table}.month) AS year + FROM {table}_by_month AS {table} + WHERE {table}.entity_id = %s + AND {table}.month >= '{year}-01-01'::date + ) {table} + USING (year, month) """ contributions_query = monthly_query.format(table="contributions", year=since) - expenditures_query = monthly_query.format(table="expenditures", year=since) cursor.execute(contributions_query, [self.id]) - columns = [c[0] for c in cursor.description] - amount_tuple = namedtuple("Amount", columns) + donation_trend = [ + [amount or 0, year, month, 1] for year, month, amount in cursor + ] - contributions = [amount_tuple(*r) for r in cursor] + expenditures_query = monthly_query.format(table="expenditures", year=since) cursor.execute(expenditures_query, [self.id]) - columns = [c[0] for c in cursor.description] - amount_tuple = namedtuple("Amount", columns) - - expenditures = [amount_tuple(*r) for r in cursor] - - donation_trend, expend_trend = [], [] - - if contributions or expenditures: - contributions_lookup = {r.month.date(): r.amount for r in contributions} - expenditures_lookup = {r.month.date(): r.amount for r in expenditures} - - start_month = datetime(int(since), 1, 1) - - end_month = ( - self.filing_set.order_by("-filed_date").first().filed_date.date() - ) - - for month in rrule(freq=MONTHLY, dtstart=start_month, until=end_month): - replacements = {"month": month.month - 1} - - if replacements["month"] < 1: - replacements["month"] = 12 - replacements["year"] = month.year - 1 - - begin_date = month.replace(**replacements) - - begin_date_array = [begin_date.year, begin_date.month, begin_date.day] - - end_date_array = [month.year, month.month, month.day] - - contribution_amount = contributions_lookup.get(month.date(), 0) - expenditure_amount = expenditures_lookup.get(month.date(), 0) - - donation_trend.append( - [begin_date_array, end_date_array, contribution_amount] - ) - expend_trend.append( - [begin_date_array, end_date_array, (-1 * expenditure_amount)] - ) - - donation_trend = stack_trends(donation_trend) - expend_trend = stack_trends(expend_trend) + expend_trend = [ + [(amount or 0) * -1, year, month, 1] for year, month, amount in cursor + ] output_trends["donation_trend"] = donation_trend output_trends["expend_trend"] = expend_trend diff --git a/camp_fin/static/js/chart_helper.js b/camp_fin/static/js/chart_helper.js index 7212ae7..cd81a4b 100644 --- a/camp_fin/static/js/chart_helper.js +++ b/camp_fin/static/js/chart_helper.js @@ -1,5 +1,12 @@ var ChartHelper = {}; -ChartHelper.donations = function(el, title, sourceTxt, yaxisLabel, data, pointInterval) { +ChartHelper.donations = function ( + el, + title, + sourceTxt, + yaxisLabel, + data, + pointInterval +) { // console.log("rendering to: #chart_" + iteration); // console.log("title: " + title); // console.log("sourceTxt: " + sourceTxt); @@ -14,434 +21,476 @@ ChartHelper.donations = function(el, title, sourceTxt, yaxisLabel, data, pointIn // var selected = data.indexOf(Date.parse(start_date)); // console.log(selected); - var color = '#007F00'; + var color = "#007F00"; - var seriesData = [{ + var seriesData = [ + { color: color, data: data, name: title, showInLegend: false, - lineWidth: 2 - }]; + lineWidth: 2, + }, + ]; //$("#charts").append("
") return new Highcharts.Chart({ - chart: { - renderTo: el, - type: "column", - marginRight: 10, - marginBottom: 25 - }, - legend: { - backgroundColor: "#ffffff", - borderColor: "#cccccc", - floating: true, - verticalAlign: "top" - }, - credits: { - enabled: false - }, - title: null, - xAxis: { - dateTimeLabelFormats: { year: "%Y" }, - type: "datetime" - }, - yAxis: { - title: yaxisLabel, - min: 0 - }, - plotOptions: { - line: { - animation: false - }, - series: { - point: { - events: { - click: function() { - var date = moment.utc(new Date(this.x)).format('YYYY-MM-DD'); - window.location.href = '/donations?date=' + date; - } - } - }, - marker: { - fillColor: color, - radius: 0, - states: { - hover: { - enabled: true, - radius: 5 - } - } + chart: { + renderTo: el, + type: "column", + marginRight: 10, + marginBottom: 25, + }, + legend: { + backgroundColor: "#ffffff", + borderColor: "#cccccc", + floating: true, + verticalAlign: "top", + }, + credits: { + enabled: false, + }, + title: null, + xAxis: { + dateTimeLabelFormats: { year: "%Y" }, + type: "datetime", + }, + yAxis: { + title: yaxisLabel, + min: 0, + }, + plotOptions: { + line: { + animation: false, + }, + series: { + point: { + events: { + click: function () { + var date = moment.utc(new Date(this.x)).format("YYYY-MM-DD"); + window.location.href = "/donations?date=" + date; + }, }, - shadow: false, + }, + marker: { + fillColor: color, + radius: 0, states: { - hover: { - lineWidth: 2 - } - } - } - }, - tooltip: { - crosshairs: true, - formatter: function() { - var s = "" + ChartHelper.toolTipDateFormat(pointInterval, this.x) + ""; - $.each(this.points, function(i, point) { - s += "
" + point.series.name + ": $" + Highcharts.numberFormat(point.y, 0, '.', ','); - }); - return s; + hover: { + enabled: true, + radius: 5, + }, }, - shared: true + }, + shadow: false, + states: { + hover: { + lineWidth: 2, + }, + }, }, - series: seriesData - }); - } + }, + tooltip: { + crosshairs: true, + formatter: function () { + var s = + "" + + ChartHelper.toolTipDateFormat(pointInterval, this.x) + + ""; + $.each(this.points, function (i, point) { + s += + "
" + + point.series.name + + ": $" + + Highcharts.numberFormat(point.y, 0, ".", ","); + }); + return s; + }, + shared: true, + }, + series: seriesData, + }); +}; -ChartHelper.netfunds = function(el, title, sourceTxt, yaxisLabel, data) { - var color = '#007E85'; +ChartHelper.netfunds = function (el, title, sourceTxt, yaxisLabel, data) { + var color = "#007E85"; - var seriesData = [{ - color: color, - data: data[0], - name: "Funds available" - },{ - color: "#DD0000", - data: data[1], - name: "Debts" - } - ] + var seriesData = [ + { + color: color, + data: data[0], + name: "Funds available", + }, + { + color: "#DD0000", + data: data[1], + name: "Debts", + }, + ]; //$("#charts").append("
") return new Highcharts.Chart({ - chart: { - renderTo: el, - type: "area", - marginRight: 10, - marginBottom: 25 - }, - legend: { - backgroundColor: "#ffffff", - borderColor: "#cccccc", - floating: true, - verticalAlign: "top" - }, - credits: { - enabled: false - }, + chart: { + renderTo: el, + type: "area", + marginRight: 10, + marginBottom: 25, + }, + legend: { + backgroundColor: "#ffffff", + borderColor: "#cccccc", + floating: true, + verticalAlign: "top", + }, + credits: { + enabled: false, + }, + title: null, + xAxis: { + dateTimeLabelFormats: { year: "%Y" }, + type: "datetime", + }, + yAxis: { title: null, - xAxis: { - dateTimeLabelFormats: { year: "%Y" }, - type: "datetime" - }, - yAxis: { - title: null - }, - plotOptions: { - line: { - animation: false - }, - series: { - marker: { - fillColor: color, - radius: 0, - states: { - hover: { - enabled: true, - radius: 5 - } - } - }, - shadow: false - } - }, - tooltip: { - crosshairs: true, - formatter: function() { - var s = "" + ChartHelper.toolTipDateFormat("day", this.x) + ""; - $.each(this.points, function(i, point) { - s += "
" + point.series.name + ": $" + Highcharts.numberFormat(point.y, 0, '.', ','); - }); - return s; + }, + plotOptions: { + line: { + animation: false, + }, + series: { + marker: { + fillColor: color, + radius: 0, + states: { + hover: { + enabled: true, + radius: 5, + }, }, - shared: true - }, - series: seriesData - }); - } + }, + shadow: false, + }, + }, + tooltip: { + crosshairs: true, + formatter: function () { + var s = + "" + + ChartHelper.toolTipDateFormat("day", this.x) + + ""; + $.each(this.points, function (i, point) { + s += + "
" + + point.series.name + + ": $" + + Highcharts.numberFormat(point.y, 0, ".", ","); + }); + return s; + }, + shared: true, + }, + series: seriesData, + }); +}; -ChartHelper.donation_expenditure = function(el, title, sourceTxt, yaxisLabel, data) { - var color = '#007E85'; +ChartHelper.donation_expenditure = function ( + el, + title, + sourceTxt, + yaxisLabel, + data +) { + var color = "#007E85"; - var seriesData = [{ - color: color, - data: data[0], - name: "Donations and loans" - },{ - color: "#DD0000", - data: data[1], - name: "Expenditures" - } - ] + var seriesData = [ + { + color: color, + data: data[0], + name: "Donations and loans", + }, + { + color: "#DD0000", + data: data[1], + name: "Expenditures", + }, + ]; //$("#charts").append("
") return new Highcharts.Chart({ - chart: { - renderTo: el, - type: "area", - marginRight: 10, - marginBottom: 25 - }, - legend: { - backgroundColor: "#ffffff", - borderColor: "#cccccc", - floating: true, - verticalAlign: "top" - }, - credits: { - enabled: false - }, + chart: { + renderTo: el, + type: "area", + marginRight: 10, + marginBottom: 25, + }, + legend: { + backgroundColor: "#ffffff", + borderColor: "#cccccc", + floating: true, + verticalAlign: "top", + }, + credits: { + enabled: false, + }, + title: null, + xAxis: { + dateTimeLabelFormats: { year: "%Y" }, + type: "datetime", + }, + yAxis: { title: null, - xAxis: { - dateTimeLabelFormats: { year: "%Y" }, - type: "datetime" - }, - yAxis: { - title: null - }, - plotOptions: { - line: { - animation: false - }, - series: { - marker: { - fillColor: color, - radius: 0, - states: { - hover: { - enabled: true, - radius: 5 - } - } - }, - shadow: false - } - }, - tooltip: { - crosshairs: true, - formatter: function() { - var s = "" + ChartHelper.toolTipDateFormat("day", this.x) + ""; - $.each(this.points, function(i, point) { - s += "
" + point.series.name + ": $" + Highcharts.numberFormat(point.y, 0, '.', ','); - }); - return s; + }, + plotOptions: { + line: { + animation: false, + }, + series: { + marker: { + fillColor: color, + radius: 0, + states: { + hover: { + enabled: true, + radius: 5, + }, }, - shared: true - }, - series: seriesData - }); - } + }, + shadow: false, + }, + }, + tooltip: { + crosshairs: true, + formatter: function () { + var s = + "" + + ChartHelper.toolTipDateFormat("month", this.x) + + ""; + $.each(this.points, function (i, point) { + s += + "
" + + point.series.name + + ": $" + + Highcharts.numberFormat(point.y, 0, ".", ","); + }); + return s; + }, + shared: true, + }, + series: seriesData, + }); +}; -ChartHelper.smallDonationExpend = function(el, min_max, data) { +ChartHelper.smallDonationExpend = function (el, min_max, data) { var min = min_max[0]; var max = min_max[1]; - var color = '#007E85'; + var color = "#007E85"; - var seriesData = [{ - color: color, - data: data[0], - name: "Donations" - },{ - color: "#DD0000", - data: data[1], - name: "Expenditures" - } - ] + var seriesData = [ + { + color: color, + data: data[0], + name: "Donations", + }, + { + color: "#DD0000", + data: data[1], + name: "Expenditures", + }, + ]; return new Highcharts.Chart({ - chart: { - renderTo: el, - type: "area", - marginRight: 10, - marginBottom: 25, - backgroundColor: null, - height: 100, - }, - legend: { - enabled: false - }, - credits: { - enabled: false - }, + chart: { + renderTo: el, + type: "area", + marginRight: 10, + marginBottom: 25, + backgroundColor: null, + height: 100, + }, + legend: { + enabled: false, + }, + credits: { + enabled: false, + }, + title: null, + xAxis: { + dateTimeLabelFormats: { year: "%Y" }, + type: "datetime", + }, + yAxis: { title: null, - xAxis: { - dateTimeLabelFormats: { year: "%Y" }, - type: "datetime" - }, - yAxis: { - title: null, - max: max, - min: min - }, - plotOptions: { - line: { - animation: false - }, - series: { - marker: { - fillColor: color, - radius: 0, - states: { - hover: { - enabled: true, - radius: 5 - } - } - }, - shadow: false - } - }, - tooltip: { - crosshairs: true, - formatter: function() { - var s = "" + ChartHelper.toolTipDateFormat("day", this.x) + ""; - $.each(this.points, function(i, point) { - s += "
" + point.series.name + ": $" + Highcharts.numberFormat(point.y, 0, '.', ','); - }); - return s; + max: max, + min: min, + }, + plotOptions: { + line: { + animation: false, + }, + series: { + marker: { + fillColor: color, + radius: 0, + states: { + hover: { + enabled: true, + radius: 5, + }, }, - shared: true - }, - series: seriesData + }, + shadow: false, + }, + }, + tooltip: { + crosshairs: true, + formatter: function () { + var s = + "" + + ChartHelper.toolTipDateFormat("day", this.x) + + ""; + $.each(this.points, function (i, point) { + s += + "
" + + point.series.name + + ": $" + + Highcharts.numberFormat(point.y, 0, ".", ","); + }); + return s; + }, + shared: true, + }, + series: seriesData, }); -} +}; -ChartHelper.initQualityChart = function(el) { - $('#' + el).highcharts({ - chart: { - type: 'bar' - }, - credits: { - enabled: false - }, - title: { - text: null - }, - xAxis: { - title: null, - labels: { - enabled: false - } - }, - yAxis:{ - title: null, - min: 1989, - max: 2015, - labels: { - formatter: function() { return parseInt(this.value); } - } - }, - plotOptions: { - series: { - stacking: 'true', - events: { - legendItemClick: function () { - return false; - } - } - } - }, - tooltip: { - borderColor: "#ccc", - formatter: function() { - return this.series.name; - } +ChartHelper.initQualityChart = function (el) { + $("#" + el).highcharts({ + chart: { + type: "bar", + }, + credits: { + enabled: false, + }, + title: { + text: null, + }, + xAxis: { + title: null, + labels: { + enabled: false, }, - legend: { reversed: true }, - series: [ - { - name: '2000 on: Electronic filings', - data: [ 15 ], - color: "#43ac6a", - }, - { - name: '1999: Incomplete', - data: [ 1 ], - color: "#d9edf7" + }, + yAxis: { + title: null, + min: 1989, + max: 2015, + labels: { + formatter: function () { + return parseInt(this.value); }, - { - name: '1994 - 1999: Manually entered', - data: [ 5 ], - color: "#43ac6a" + }, + }, + plotOptions: { + series: { + stacking: "true", + events: { + legendItemClick: function () { + return false; + }, }, - { - name: '1989 - 1994: Bad entries', - data: [ 1994 ], - color: "#d9edf7" - } - ] + }, + }, + tooltip: { + borderColor: "#ccc", + formatter: function () { + return this.series.name; + }, + }, + legend: { reversed: true }, + series: [ + { + name: "2000 on: Electronic filings", + data: [15], + color: "#43ac6a", + }, + { + name: "1999: Incomplete", + data: [1], + color: "#d9edf7", + }, + { + name: "1994 - 1999: Manually entered", + data: [5], + color: "#43ac6a", + }, + { + name: "1989 - 1994: Bad entries", + data: [1994], + color: "#d9edf7", + }, + ], }); -} +}; -ChartHelper.pointInterval = function(interval) { - if (interval == "year") - return 365 * 24 * 3600 * 1000; - if (interval == "quarter") - return 3 * 30.4 * 24 * 3600 * 1000; - if (interval == "month") //this is very hacky. months have different day counts, so our point interval is the average - 30.4 +ChartHelper.pointInterval = function (interval) { + if (interval == "year") return 365 * 24 * 3600 * 1000; + if (interval == "quarter") return 3 * 30.4 * 24 * 3600 * 1000; + if (interval == "month") + //this is very hacky. months have different day counts, so our point interval is the average - 30.4 return 30.4 * 24 * 3600 * 1000; - if (interval == "week") - return 7 * 24 * 3600 * 1000; - if (interval == "day") - return 24 * 3600 * 1000; - if (interval == "hour") - return 3600 * 1000; - else - return 1; -} + if (interval == "week") return 7 * 24 * 3600 * 1000; + if (interval == "day") return 24 * 3600 * 1000; + if (interval == "hour") return 3600 * 1000; + else return 1; +}; -ChartHelper.toolTipDateFormat = function(interval, x) { - if (interval == "year") - return Highcharts.dateFormat("%Y", x); - if (interval == "quarter") - return Highcharts.dateFormat("%B %Y", x); - if (interval == "month") - return Highcharts.dateFormat("%B %Y", x); - if (interval == "week") - return Highcharts.dateFormat("%e %b %Y", x); - if (interval == "day") - return Highcharts.dateFormat("%e %b %Y", x); - if (interval == "hour") - return Highcharts.dateFormat("%H:00", x); - else - return 1; -} +ChartHelper.toolTipDateFormat = function (interval, x) { + if (interval == "year") return Highcharts.dateFormat("%Y", x); + if (interval == "quarter") return Highcharts.dateFormat("%B %Y", x); + if (interval == "month") return Highcharts.dateFormat("%B %Y", x); + if (interval == "week") return Highcharts.dateFormat("%e %b %Y", x); + if (interval == "day") return Highcharts.dateFormat("%e %b %Y", x); + if (interval == "hour") return Highcharts.dateFormat("%H:00", x); + else return 1; +}; // x is difference between beginning and end of reporting period in weeks (can be a float) // y amount contributed within period / x -ChartHelper.generateSeriesData = function(listOfData) { - var sumX = 0.0; - for (var i = 0; i < listOfData.length; i++) { - sumX += listOfData[i].x; - } - var gap = sumX / listOfData.length * 0.2; - var allSeries = [] - var x = 0.0; - for (var i = 0; i < listOfData.length; i++) { - var data = listOfData[i]; - allSeries[i] = { - name: data.name, - data: [ - [x, 0], [x, data.y], - { - x: x + data.x / 2.0, - y: data.y - // dataLabels: { enabled: true, format: data.x + ' x {y}' } - }, - [x + data.x, data.y], [x + data.x, 0] - ], - w: data.x, - h: data.y - }; - x += data.x + gap; - } - return allSeries; -} +ChartHelper.generateSeriesData = function (listOfData) { + var sumX = 0.0; + for (var i = 0; i < listOfData.length; i++) { + sumX += listOfData[i].x; + } + var gap = (sumX / listOfData.length) * 0.2; + var allSeries = []; + var x = 0.0; + for (var i = 0; i < listOfData.length; i++) { + var data = listOfData[i]; + allSeries[i] = { + name: data.name, + data: [ + [x, 0], + [x, data.y], + { + x: x + data.x / 2.0, + y: data.y, + // dataLabels: { enabled: true, format: data.x + ' x {y}' } + }, + [x + data.x, data.y], + [x + data.x, 0], + ], + w: data.x, + h: data.y, + }; + x += data.x + gap; + } + return allSeries; +};