Skip to content
This repository was archived by the owner on Feb 6, 2026. It is now read-only.

Commit 934be5f

Browse files
Merge pull request #53 from ShorensteinCenter/devel
[Feature, N/A] Add front end visualizations
2 parents 051cd23 + 6db3990 commit 934be5f

26 files changed

Lines changed: 946 additions & 226 deletions

app/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
# Set up flask-talisman to prevent xss and other attacks
2020
csp = {
2121
'default-src': '\'self\'',
22-
'script-src': ['\'self\'', 'cdnjs.cloudflare.com', 'www.googletagmanager.com'],
23-
'style-src': ['\'self\'', 'fonts.googleapis.com'],
22+
'script-src': ['\'self\'', 'cdnjs.cloudflare.com', 'cdn.jsdelivr.net',
23+
'www.googletagmanager.com', 'cdn.plot.ly'],
24+
'style-src': ['\'self\'', 'fonts.googleapis.com',
25+
'\'unsafe-inline\'', 'cdn.jsdelivr.net'],
2426
'font-src': ['\'self\'', 'fonts.gstatic.com'],
2527
'img-src': ['\'self\'', 'www.google-analytics.com', 'data:']}
2628
Talisman(app, content_security_policy=csp,
27-
content_security_policy_nonce_in=['script-src', 'style-src'])
29+
content_security_policy_nonce_in=['script-src'])
2830

2931
csrf = CSRFProtect(app)
3032
db = SQLAlchemy(app)

app/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __repr__(self):
5353
class EmailList(db.Model): # pylint: disable=too-few-public-methods
5454
"""Stores individual MailChimp lists."""
5555
list_id = db.Column(db.String(64), primary_key=True)
56+
creation_timestamp = db.Column(db.DateTime)
5657
list_name = db.Column(db.String(128))
5758
api_key = db.Column(db.String(64))
5859
data_center = db.Column(db.String(64))

app/routes.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""This module contains all routes for the web app."""
22
import hashlib
33
import json
4+
from datetime import datetime
45
import requests
56
from titlecase import titlecase
67
import pandas as pd
@@ -15,8 +16,30 @@
1516

1617
@app.route('/')
1718
def index():
18-
"""Index route."""
19-
return render_template('index.html')
19+
"""Index route.
20+
21+
Pulls the creation timestamp, number of subscribers and open rate for each
22+
list which allow data aggregation. The computes the age of each list."""
23+
lists_allow_aggregation = pd.read_sql(
24+
ListStats.query.join(EmailList)
25+
.filter_by(store_aggregates=True)
26+
.order_by(ListStats.list_id, desc('analysis_timestamp'))
27+
.distinct(ListStats.list_id)
28+
.with_entities(EmailList.creation_timestamp,
29+
ListStats.subscribers,
30+
ListStats.open_rate)
31+
.statement,
32+
db.session.bind)
33+
lists_allow_aggregation.dropna(inplace=True)
34+
current_timestamp = datetime.utcnow()
35+
lists_allow_aggregation['list_age'] = (
36+
lists_allow_aggregation['creation_timestamp'].apply(
37+
lambda timestamp: int((current_timestamp - timestamp).days / 30)))
38+
return render_template(
39+
'index.html',
40+
sizes=list(lists_allow_aggregation['subscribers']),
41+
open_rates=list(lists_allow_aggregation['open_rate']),
42+
ages=list(lists_allow_aggregation['list_age']))
2043

2144
@app.route('/about')
2245
def about():
@@ -40,7 +63,12 @@ def privacy():
4063

4164
@app.route('/faq')
4265
def faq():
43-
"""FAQ route."""
66+
"""FAQ route.
67+
68+
Calculates the percentage of organizations associated with
69+
a list in the database that fall into various subcategories, e.g.
70+
% Non-Profit, % For-Profit, % B Corp. Then calculates aggregates for
71+
list data among lists which allow their data to be aggregated."""
4472

4573
# Get information about organizations
4674
orgs_with_lists = pd.read_sql(
@@ -342,7 +370,7 @@ def analyze_list():
342370
'store_aggregates': session['store_aggregates'],
343371
'total_count': content['total_count'],
344372
'open_rate': content['open_rate'],
345-
'date_created': content['date_created'],
373+
'creation_timestamp': content['date_created'],
346374
'campaign_count': content['campaign_count']}
347375
org_id = session['org_id']
348376
init_list_analysis.delay(user_data, list_data, org_id)

app/static/css/styles.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/static/es/charts.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
const indexBubbleChart = document.getElementById('index-bubble-chart');
2+
3+
/* Calculates the difference, in months, between two Date objects */
4+
const dateDiff = pastDate => Math.floor((new Date() - pastDate) / 2592000000);
5+
6+
/* Updates the index page bubble chart with the current values of the list size,
7+
open rate, and date created field */
8+
const updateChart = speed => {
9+
const
10+
userSubscribers = parseInt(document.getElementById('enter-list-size')
11+
.value.replace(/,/g, '')),
12+
userOpenRate = +(document.getElementById('enter-open-rate')
13+
.value.replace('%', '')),
14+
userListCreated = document.getElementById('enter-list-age').value;
15+
if (isNaN(userSubscribers) || userSubscribers < 0 ||
16+
isNaN(userOpenRate) || userOpenRate < 0 || userOpenRate > 100)
17+
return;
18+
const
19+
userListAge = dateDiff(
20+
new Date(new Date(userListCreated).toUTCString())),
21+
openRateFormatted = userOpenRate.toFixed(1),
22+
animation = Plotly.animate(indexBubbleChart, {
23+
data: [
24+
{x: [userListAge],
25+
y: [openRateFormatted],
26+
text: ['Age: ' + userListAge + ' months<br>' +
27+
'Open Rate: ' + openRateFormatted + '%<br>' +
28+
'Subscribers: ' + userSubscribers.toLocaleString()],
29+
marker: {
30+
size: [userSubscribers],
31+
}
32+
}
33+
],
34+
traces: [1]
35+
}, {
36+
transition: {
37+
duration: speed,
38+
easing: 'ease',
39+
},
40+
frame: {
41+
duration: speed
42+
}
43+
});
44+
return animation;
45+
}
46+
47+
if (indexBubbleChart) {
48+
const
49+
subscribers = JSON.parse(indexBubbleChart.getAttribute('data-subscribers')),
50+
openRates = JSON.parse(
51+
indexBubbleChart.getAttribute('data-open-rates'))
52+
.map(val => Math.round(1000 * val) / 10),
53+
listAges = JSON.parse(indexBubbleChart.getAttribute('data-ages')),
54+
janFirstDate = new Date(
55+
new Date(new Date().getFullYear() - 5, 0, 1).toUTCString());
56+
57+
// Bubble chart data from the database
58+
const dbData = {
59+
x: listAges,
60+
y: openRates,
61+
text: Array.from(
62+
{length: listAges.length},
63+
(v, i) =>
64+
'Age: ' + listAges[i] + ' months<br>' +
65+
'Open Rate: ' + openRates[i] + '%<br>' +
66+
'Subscribers: ' + subscribers[i].toLocaleString()
67+
),
68+
hoverinfo: 'text',
69+
hoverlabel: {
70+
bgcolor: 'rgba(167, 25, 48, .85)',
71+
font: {
72+
family: 'Montserrat, sans-serif',
73+
size: 12
74+
},
75+
76+
},
77+
mode: 'markers',
78+
marker: {
79+
size: subscribers,
80+
sizeref: 2.0 * Math.max(...subscribers) / (60**2),
81+
sizemode: 'area',
82+
color: new Array(subscribers.length).fill('rgba(167, 25, 48, .85)')
83+
}
84+
};
85+
86+
// Prepopulated dummy 'user' data
87+
const userData = {
88+
x: [dateDiff(janFirstDate)],
89+
y: [7.5],
90+
text: ['Age: ' + dateDiff(janFirstDate) +
91+
' months<br>Open Rate: 7.5%<br>Subscribers: 5,000'],
92+
hoverinfo: 'text',
93+
hoverlabel: {
94+
bgcolor: 'rgba(215, 164, 45, 0.85)',
95+
font: {
96+
color: 'white',
97+
family: 'Montserrat, sans-serif',
98+
size: 12
99+
},
100+
bordercolor: 'white'
101+
},
102+
mode: 'markers',
103+
marker: {
104+
size: [5000],
105+
sizeref: 2.0 * Math.max(...subscribers) / (60**2),
106+
sizemode: 'area',
107+
color: ['rgba(215, 164, 45, 0.85)']
108+
}
109+
};
110+
111+
const data = [dbData, userData];
112+
113+
// Bubble chart visual appearance
114+
const layout = {
115+
font: {
116+
family: 'Montserrat, sans-serif',
117+
size: 16,
118+
},
119+
yaxis: {
120+
range: [0, (1.25 * Math.max(...openRates) > 100) ? 100 :
121+
1.25 * Math.max(...openRates)],
122+
color: '#aaa',
123+
tickfont: {
124+
color: '#555'
125+
},
126+
tickprefix: ' ',
127+
ticksuffix: '% ',
128+
title: 'List Open Rate',
129+
titlefont: {
130+
color: '#555'
131+
},
132+
automargin: true,
133+
fixedrange: true
134+
},
135+
xaxis: {
136+
range: [0, 1.15 * Math.max(...listAges)],
137+
color: '#aaa',
138+
tickfont: {
139+
color: '#555'
140+
},
141+
tickformat: ',',
142+
title: 'List Age (Months)',
143+
titlefont: {
144+
color: '#555'
145+
},
146+
fixedrange: true
147+
},
148+
showlegend: false,
149+
height: 525,
150+
margin: {
151+
t: 5,
152+
b: 105
153+
},
154+
hovermode: 'closest'
155+
};
156+
157+
const config = {
158+
responsive: true,
159+
displayModeBar: false
160+
};
161+
162+
Plotly.newPlot(indexBubbleChart, data, layout, config);
163+
164+
// Instantiate a flatpickr date picker widget on the list age field
165+
flatpickr('#enter-list-age', {
166+
defaultDate: janFirstDate,
167+
maxDate: 'today',
168+
dateFormat: 'm/d/Y'
169+
});
170+
171+
const enterStatsFields = document.querySelectorAll('.enter-stats input');
172+
for (let i = 0; i < enterStatsFields.length; ++i) {
173+
const elt = enterStatsFields[i];
174+
elt.addEventListener('change', () => updateChart(450));
175+
}
176+
177+
/* Event listener which triggers an animation when the chart comes into view */
178+
const chartVisibleHandler = () => {
179+
const
180+
rect = indexBubbleChart.getBoundingClientRect(),
181+
top = rect.top,
182+
bottom = rect.bottom - 45;
183+
if (top >= 0 && bottom <= window.innerHeight) {
184+
const
185+
listSizeField = document.getElementById('enter-list-size'),
186+
openRateField = document.getElementById('enter-open-rate');
187+
listSizeField.value = '25,000';
188+
openRateField.value = '30%';
189+
updateChart(1500);
190+
document.removeEventListener('scroll', debouncedChartHandler);
191+
}
192+
}
193+
194+
const debouncedChartHandler = debounced(50, chartVisibleHandler);
195+
196+
document.addEventListener('scroll', debouncedChartHandler);
197+
}

0 commit comments

Comments
 (0)