Browse Source

Add new dashboard (with new metrics)

Kailash Nadh 5 years ago
parent
commit
feb5ba09be

+ 20 - 9
admin.go

@@ -17,10 +17,6 @@ type configScript struct {
 	Messengers []string `json:"messengers"`
 }
 
-type dashboardStats struct {
-	Stats types.JSONText `db:"stats"`
-}
-
 // handleGetConfigScript returns general configuration as a Javascript
 // variable that can be included in an HTML page directly.
 func handleGetConfigScript(c echo.Context) error {
@@ -41,17 +37,32 @@ func handleGetConfigScript(c echo.Context) error {
 	return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
 }
 
-// handleGetDashboardStats returns general states for the dashboard.
-func handleGetDashboardStats(c echo.Context) error {
+// handleGetDashboardCharts returns chart data points to render ont he dashboard.
+func handleGetDashboardCharts(c echo.Context) error {
 	var (
 		app = c.Get("app").(*App)
-		out dashboardStats
+		out types.JSONText
 	)
 
-	if err := app.queries.GetDashboardStats.Get(&out); err != nil {
+	if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
 		return echo.NewHTTPError(http.StatusInternalServerError,
 			fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err)))
 	}
 
-	return c.JSON(http.StatusOK, okResp{out.Stats})
+	return c.JSON(http.StatusOK, okResp{out})
+}
+
+// handleGetDashboardCounts returns stats counts to show on the dashboard.
+func handleGetDashboardCounts(c echo.Context) error {
+	var (
+		app = c.Get("app").(*App)
+		out types.JSONText
+	)
+
+	if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			fmt.Sprintf("Error fetching dashboard statsc counts: %s", pqErrMsg(err)))
+	}
+
+	return c.JSON(http.StatusOK, okResp{out})
 }

+ 2 - 0
frontend/package.json

@@ -11,6 +11,7 @@
   "dependencies": {
     "axios": "^0.19.2",
     "buefy": "^0.8.20",
+    "c3": "^0.7.18",
     "core-js": "^3.6.5",
     "dayjs": "^1.8.28",
     "humps": "^2.0.1",
@@ -20,6 +21,7 @@
     "quill-delta": "^4.2.2",
     "sass-loader": "^8.0.2",
     "vue": "^2.6.11",
+    "vue-c3": "^1.2.11",
     "vue-quill-editor": "^3.0.6",
     "vue-router": "^3.2.0",
     "vuex": "^3.4.0"

+ 7 - 0
frontend/src/api/index.js

@@ -79,6 +79,13 @@ http.interceptors.response.use((resp) => {
 // loading: modelName (set's the loading status in the global store: eg: store.loading.lists = true)
 // store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
 
+// Dashboard
+export const getDashboardCounts = () => http.get('/api/dashboard/counts',
+  { loading: models.dashboard });
+
+export const getDashboardCharts = () => http.get('/api/dashboard/charts',
+  { loading: models.dashboard });
+
 // Lists.
 export const getLists = () => http.get('/api/lists',
   { loading: models.lists, store: models.lists });

+ 65 - 12
frontend/src/assets/style.scss

@@ -111,7 +111,7 @@ section {
       margin-right: 0;
     }
     > li {
-      margin-bottom: 5px;
+      margin-bottom: 15px;
     }
   }
   .logo {
@@ -229,20 +229,34 @@ section {
 
 /* Dashboard */
 section.dashboard {
-  .counts {
-    .title {
-      margin-bottom: 1rem;
-    }
-    .level-item {
-      background-color: $white-bis;
-      padding: 30px;
-      margin: 10px;
+  .title {
+    margin-bottom: 0.5rem;
+  }
 
-      &:first-child, &:last-child {
-        margin: 0;
-      }
+  .level-item {
+    background-color: $white-bis;
+    padding: 30px;
+    margin: 10px;
+
+    &:first-child, &:last-child {
+      margin: 0;
     }
   }
+
+  label {
+    font-weight: bold;
+    display: inline-block;
+    min-width: 50px;
+    text-align: right;
+  }
+
+  .tile {
+    position: relative;
+  }
+
+  .charts {
+    min-height: 200px;
+  }
 }
 
 /* Lists page */
@@ -429,6 +443,39 @@ section.campaign {
   }
 }
 
+.c3 {
+  .c3-chart-lines .c3-line {
+    stroke-width: 2px;
+  }
+  .c3-axis-x .tick line,
+  .c3-axis-y .tick line {
+    display: none;
+  }
+  text {
+    fill: $grey;
+    font-family: $body-family;
+    font-size: 11px;
+  }
+  .c3-axis path, .c3-axis line {
+    stroke: #eee;
+  }
+
+  .c3-tooltip {
+    border: 0;
+    background-color: #fff;
+    empty-cells: show;
+    box-shadow: none;
+    opacity: 0.9;
+
+    tr {
+      border: 0;
+    }
+    th {
+      background: $white;
+    }
+  }
+}
+
 @media screen and (max-width: 1450px) and (min-width: 769px) {
   section.campaigns {
     /* Fold the stats labels until the card view */
@@ -478,3 +525,9 @@ section.campaign {
     margin: 15px;
   }
 }
+
+@media screen and (max-width: 840px) {
+  section.dashboard label {
+    min-width: auto;
+  }
+}

+ 20 - 0
frontend/src/components/EmptyPlaceholder.vue

@@ -0,0 +1,20 @@
+<template>
+    <section class="section">
+        <div class="content has-text-grey has-text-centered">
+            <p>
+                <b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
+            </p>
+            <p>{{ !label ? 'Nothing here yet' : label  }}</p>
+        </div>
+    </section>
+</template>
+
+
+<script>
+export default {
+  name: 'EmptyPlaceholder',
+  props: {
+    icon: String,
+    label: String,
+  },
+};

+ 5 - 0
frontend/src/constants.js

@@ -1,4 +1,5 @@
 export const models = Object.freeze({
+  dashboard: 'dashboard',
   lists: 'lists',
   subscribers: 'subscribers',
   campaigns: 'campaigns',
@@ -20,3 +21,7 @@ export const storeKeys = Object.freeze({
 });
 
 export const timestamp = 'ddd D MMM YYYY, hh:mm A';
+
+export const colors = Object.freeze({
+  primary: '#7f2aff',
+});

+ 4 - 0
frontend/src/utils.js

@@ -31,6 +31,10 @@ export default class utils {
   static validateEmail = (e) => e.match(reEmail);
 
   static niceNumber = (n) => {
+    if (n === null || n === undefined) {
+      return 0;
+    }
+
     let pfx = '';
     let div = 1;
 

+ 190 - 30
frontend/src/views/Dashboard.vue

@@ -4,55 +4,215 @@
       <div class="column is-two-thirds">
         <h1 class="title is-5">{{ dayjs().format("ddd, DD MMM") }}</h1>
       </div>
-      <div class="column has-text-right">
-        <b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
-      </div>
     </header>
 
-    <div class="columns counts">
-      <div class="column is-half">
-        <div class="level">
-          <div class="level-item has-text-centered">
-            <div>
-              <p class="title">0</p>
-              <p class="heading">Subscribers</p>
-            </div>
-          </div>
-          <div class="level-item has-text-centered">
-            <div>
-              <p class="title">0</p>
-              <p class="heading">Lists</p>
-            </div>
-          </div>
-          <div class="level-item has-text-centered">
-            <div>
-              <p class="title">0</p>
-              <p class="heading">Campaigns</p>
+    <section class="counts wrap-small">
+      <div class="tile is-ancestor">
+        <div class="tile is-vertical is-12">
+          <div class="tile">
+            <div class="tile is-parent is-vertical">
+              <b-loading v-if="isCountsLoading" active :is-full-page="false" />
+              <article class="tile is-child notification">
+                <div class="columns is-mobile">
+                  <div class="column is-6">
+                    <p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
+                    <p class="is-size-6 has-text-grey">Lists</p>
+                  </div>
+                  <div class="column is-6">
+                    <ul class="no is-size-7 has-text-grey">
+                      <li>
+                        <label>{{ $utils.niceNumber(counts.lists.public) }}</label> public
+                      </li>
+                      <li>
+                        <label>{{ $utils.niceNumber(counts.lists.private) }}</label> private
+                      </li>
+                      <li>
+                        <label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
+                        single opt-in
+                      </li>
+                      <li>
+                        <label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
+                        double opt-in</li>
+                    </ul>
+                  </div>
+                </div>
+              </article><!-- lists -->
+
+              <article class="tile is-child notification">
+                <div class="columns is-mobile">
+                  <div class="column is-6">
+                    <p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
+                    <p class="is-size-6 has-text-grey">Campaigns</p>
+                  </div>
+                  <div class="column is-6">
+                    <ul class="no is-size-7 has-text-grey">
+                      <li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
+                        <label>{{ num }}</label> {{ status }}
+                      </li>
+                    </ul>
+                  </div>
+                </div>
+              </article><!-- campaigns -->
+            </div><!-- block -->
+
+            <div class="tile is-parent">
+              <b-loading v-if="isCountsLoading" active :is-full-page="false" />
+              <article class="tile is-child notification">
+                <div class="columns is-mobile">
+                  <div class="column is-6">
+                    <p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
+                    <p class="is-size-6 has-text-grey">Subscribers</p>
+                  </div>
+
+                  <div class="column is-6">
+                    <ul class="no is-size-7 has-text-grey">
+                      <li>
+                        <label>{{ $utils.niceNumber(counts.subscribers.blacklisted) }}</label>
+                        blacklisted
+                      </li>
+                      <li>
+                        <label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
+                        orphans
+                      </li>
+                    </ul>
+                  </div><!-- subscriber breakdown -->
+                </div><!-- subscriber columns -->
+                <hr />
+                <div class="columns">
+                  <div class="column is-6">
+                    <p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
+                    <p class="is-size-6 has-text-grey">Messages sent</p>
+                  </div>
+                </div>
+              </article><!-- subscribers -->
             </div>
           </div>
-          <div class="level-item has-text-centered">
-            <div>
-              <p class="title">0</p>
-              <p class="heading">Messages sent</p>
-            </div>
+          <div class="tile is-parent">
+            <b-loading v-if="isChartsLoading" active :is-full-page="false" />
+            <article class="tile is-child notification charts">
+              <div class="columns">
+                <div class="column is-6">
+                  <h3 class="title is-size-6 has-text-right">Campaign views</h3>
+                  <vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
+                  <empty-placeholder v-else-if="!isChartsLoading" />
+                </div>
+                <div class="column is-6">
+                  <h3 class="title is-size-6 has-text-right">Link clicks</h3>
+                  <vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
+                  <empty-placeholder v-else-if="!isChartsLoading" />
+                </div>
+              </div>
+            </article>
           </div>
         </div>
-      </div>
-    </div>
+      </div><!-- tile block -->
+    </section>
   </section>
 </template>
 
+
+<style lang="css">
+  @import "~c3/c3.css";
+</style>
+
 <script>
 import Vue from 'vue';
+import VueC3 from 'vue-c3';
 import dayjs from 'dayjs';
+import { colors } from '../constants';
+import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
 
 export default Vue.extend({
-  name: 'Home',
+  components: {
+    EmptyPlaceholder,
+    VueC3,
+  },
+
+  data() {
+    return {
+      // Unique Vue() instances for each chart.
+      chartViewsInst: null,
+      chartClicksInst: null,
+
+      isChartsLoading: true,
+      isCountsLoading: true,
+
+      counts: {
+        lists: {},
+        subscribers: {},
+        campaigns: {},
+        messages: 0,
+      },
+    };
+  },
+
+  methods: {
+    makeChart(label, data) {
+      const conf = {
+        data: {
+          columns: [
+            [label, ...data.map((d) => d.count).reverse()],
+          ],
+          type: 'spline',
+          color() {
+            return colors.primary;
+          },
+        },
+        axis: {
+          x: {
+            type: 'category',
+            categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
+            tick: {
+              rotate: -45,
+              multiline: false,
+              culling: { max: 10 },
+            },
+          },
+        },
+        legend: {
+          show: false,
+        },
+      };
+      return conf;
+    },
+  },
 
   computed: {
     dayjs() {
       return dayjs;
     },
   },
+
+  mounted() {
+    // Pull the counts.
+    this.$api.getDashboardCounts().then((r) => {
+      this.counts = r.data;
+      this.isCountsLoading = false;
+    });
+
+    // Pull the charts.
+    this.$api.getDashboardCharts().then((r) => {
+      this.isChartsLoading = false;
+
+      // vue-c3 lib requires unique instances of Vue() to communicate.
+      if (r.data.campaignViews.length > 0) {
+        this.chartViewsInst = this;
+
+        this.$nextTick(() => {
+          this.chartViewsInst.$emit('init',
+            this.makeChart('Campaign views', r.data.campaignViews));
+        });
+      }
+
+      if (r.data.linkClicks.length > 0) {
+        this.chartClicksInst = new Vue();
+
+        this.$nextTick(() => {
+          this.chartClicksInst.$emit('init',
+            this.makeChart('Link clicks', r.data.linkClicks));
+        });
+      }
+    });
+  },
 });
 </script>

+ 2 - 2
frontend/src/views/Lists.vue

@@ -40,7 +40,7 @@
                 </router-link>
             </b-table-column>
 
-            <b-table-column field="subscribers" label="Subscribers" numeric sortable centered>
+            <b-table-column field="subscriberCount" label="Subscribers" numeric sortable centered>
                 <router-link :to="`/subscribers/lists/${props.row.id}`">
                   {{ props.row.subscriberCount }}
                 </router-link>
@@ -78,7 +78,7 @@
                     <p>
                         <b-icon icon="plus" size="is-large" />
                     </p>
-                    <p>Nothing here.</p>
+                    <p>Nothing here yet.</p>
                 </div>
             </section>
         </template>

+ 271 - 6
frontend/yarn.lock

@@ -1992,6 +1992,13 @@ bytes@3.1.0:
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
   integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
 
+c3@^0.7.18:
+  version "0.7.18"
+  resolved "https://registry.yarnpkg.com/c3/-/c3-0.7.18.tgz#a94228191b6178288fa416a6135c9624abd8dc45"
+  integrity sha512-ioiqCvET2sjAn80V3qVBEkyAtCH3tktZsz9SylGmUeeGEfqZxZfq9qRCxfgl64LpA6d9/Oz4C8oYEIXHbMX/Ow==
+  dependencies:
+    d3 "^5.8.0"
+
 cacache@^12.0.2, cacache@^12.0.3:
   version "12.0.4"
   resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c"
@@ -2414,16 +2421,16 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
   dependencies:
     delayed-stream "~1.0.0"
 
+commander@2, commander@^2.18.0, commander@^2.20.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
 commander@2.17.x:
   version "2.17.1"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
   integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
 
-commander@^2.18.0, commander@^2.20.0:
-  version "2.20.3"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
-  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
-
 commander@~2.19.0:
   version "2.19.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
@@ -2863,6 +2870,254 @@ cyclist@^1.0.1:
   resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
   integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
 
+d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
+  integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
+
+d3-axis@1:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9"
+  integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==
+
+d3-brush@1:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-1.1.5.tgz#066b8e84d17b192986030446c97c0fba7e1bacdc"
+  integrity sha512-rEaJ5gHlgLxXugWjIkolTA0OyMvw8UWU1imYXy1v642XyyswmI1ybKOv05Ft+ewq+TFmdliD3VuK0pRp1VT/5A==
+  dependencies:
+    d3-dispatch "1"
+    d3-drag "1"
+    d3-interpolate "1"
+    d3-selection "1"
+    d3-transition "1"
+
+d3-chord@1:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-1.0.6.tgz#309157e3f2db2c752f0280fedd35f2067ccbb15f"
+  integrity sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==
+  dependencies:
+    d3-array "1"
+    d3-path "1"
+
+d3-collection@1:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
+  integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==
+
+d3-color@1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
+  integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
+
+d3-contour@1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.3.2.tgz#652aacd500d2264cb3423cee10db69f6f59bead3"
+  integrity sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==
+  dependencies:
+    d3-array "^1.1.1"
+
+d3-dispatch@1:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58"
+  integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==
+
+d3-drag@1:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.5.tgz#2537f451acd39d31406677b7dc77c82f7d988f70"
+  integrity sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==
+  dependencies:
+    d3-dispatch "1"
+    d3-selection "1"
+
+d3-dsv@1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-1.2.0.tgz#9d5f75c3a5f8abd611f74d3f5847b0d4338b885c"
+  integrity sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==
+  dependencies:
+    commander "2"
+    iconv-lite "0.4"
+    rw "1"
+
+d3-ease@1:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.6.tgz#ebdb6da22dfac0a22222f2d4da06f66c416a0ec0"
+  integrity sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ==
+
+d3-fetch@1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-1.2.0.tgz#15ce2ecfc41b092b1db50abd2c552c2316cf7fc7"
+  integrity sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==
+  dependencies:
+    d3-dsv "1"
+
+d3-force@1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-1.2.1.tgz#fd29a5d1ff181c9e7f0669e4bd72bdb0e914ec0b"
+  integrity sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==
+  dependencies:
+    d3-collection "1"
+    d3-dispatch "1"
+    d3-quadtree "1"
+    d3-timer "1"
+
+d3-format@1:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.4.tgz#356925f28d0fd7c7983bfad593726fce46844030"
+  integrity sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw==
+
+d3-geo@1:
+  version "1.12.1"
+  resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.12.1.tgz#7fc2ab7414b72e59fbcbd603e80d9adc029b035f"
+  integrity sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==
+  dependencies:
+    d3-array "1"
+
+d3-hierarchy@1:
+  version "1.1.9"
+  resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
+  integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
+
+d3-interpolate@1:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
+  integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==
+  dependencies:
+    d3-color "1"
+
+d3-path@1:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
+  integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
+
+d3-polygon@1:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
+  integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
+
+d3-quadtree@1:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.7.tgz#ca8b84df7bb53763fe3c2f24bd435137f4e53135"
+  integrity sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==
+
+d3-random@1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-1.1.2.tgz#2833be7c124360bf9e2d3fd4f33847cfe6cab291"
+  integrity sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==
+
+d3-scale-chromatic@1:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98"
+  integrity sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==
+  dependencies:
+    d3-color "1"
+    d3-interpolate "1"
+
+d3-scale@2:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
+  integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
+  dependencies:
+    d3-array "^1.2.0"
+    d3-collection "1"
+    d3-format "1"
+    d3-interpolate "1"
+    d3-time "1"
+    d3-time-format "2"
+
+d3-selection@1, d3-selection@^1.1.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.1.tgz#98eedbbe085fbda5bafa2f9e3f3a2f4d7d622a98"
+  integrity sha512-BTIbRjv/m5rcVTfBs4AMBLKs4x8XaaLkwm28KWu9S2vKNqXkXt2AH2Qf0sdPZHjFxcWg/YL53zcqAz+3g4/7PA==
+
+d3-shape@1:
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
+  integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==
+  dependencies:
+    d3-path "1"
+
+d3-time-format@2:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.2.3.tgz#0c9a12ee28342b2037e5ea1cf0b9eb4dd75f29cb"
+  integrity sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==
+  dependencies:
+    d3-time "1"
+
+d3-time@1:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
+  integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
+
+d3-timer@1:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5"
+  integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==
+
+d3-transition@1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398"
+  integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==
+  dependencies:
+    d3-color "1"
+    d3-dispatch "1"
+    d3-ease "1"
+    d3-interpolate "1"
+    d3-selection "^1.1.0"
+    d3-timer "1"
+
+d3-voronoi@1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297"
+  integrity sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==
+
+d3-zoom@1:
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-1.8.3.tgz#b6a3dbe738c7763121cd05b8a7795ffe17f4fc0a"
+  integrity sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==
+  dependencies:
+    d3-dispatch "1"
+    d3-drag "1"
+    d3-interpolate "1"
+    d3-selection "1"
+    d3-transition "1"
+
+d3@^5.8.0:
+  version "5.16.0"
+  resolved "https://registry.yarnpkg.com/d3/-/d3-5.16.0.tgz#9c5e8d3b56403c79d4ed42fbd62f6113f199c877"
+  integrity sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==
+  dependencies:
+    d3-array "1"
+    d3-axis "1"
+    d3-brush "1"
+    d3-chord "1"
+    d3-collection "1"
+    d3-color "1"
+    d3-contour "1"
+    d3-dispatch "1"
+    d3-drag "1"
+    d3-dsv "1"
+    d3-ease "1"
+    d3-fetch "1"
+    d3-force "1"
+    d3-format "1"
+    d3-geo "1"
+    d3-hierarchy "1"
+    d3-interpolate "1"
+    d3-path "1"
+    d3-polygon "1"
+    d3-quadtree "1"
+    d3-random "1"
+    d3-scale "2"
+    d3-scale-chromatic "1"
+    d3-selection "1"
+    d3-shape "1"
+    d3-time "1"
+    d3-time-format "2"
+    d3-timer "1"
+    d3-transition "1"
+    d3-voronoi "1"
+    d3-zoom "1"
+
 dashdash@^1.12.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
@@ -4504,7 +4759,7 @@ humps@^2.0.1:
   resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
   integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24:
+iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -7397,6 +7652,11 @@ run-queue@^1.0.0, run-queue@^1.0.3:
   dependencies:
     aproba "^1.1.1"
 
+rw@1:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
+  integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=
+
 rxjs@^6.5.3:
   version "6.5.5"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec"
@@ -8646,6 +8906,11 @@ vm-browserify@^1.0.1:
   resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
   integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
 
+vue-c3@^1.2.11:
+  version "1.2.11"
+  resolved "https://registry.yarnpkg.com/vue-c3/-/vue-c3-1.2.11.tgz#6937f0dd54addab2b76de74cd30c0ab9ad788080"
+  integrity sha512-jxYZ726lKO1Qa+CHOcekPD4ZIwcMQy2LYDafYy2jYD1oswAo/4SnEJmbwp9X+NWzZg/KIAijeB9ImS7Gfvhceg==
+
 vue-eslint-parser@^7.0.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.1.0.tgz#9cdbcc823e656b087507a1911732b867ac101e83"

+ 2 - 1
handlers.go

@@ -38,7 +38,8 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[
 func registerHTTPHandlers(e *echo.Echo) {
 	e.GET("/", handleIndexPage)
 	e.GET("/api/config.js", handleGetConfigScript)
-	e.GET("/api/dashboard/stats", handleGetDashboardStats)
+	e.GET("/api/dashboard/charts", handleGetDashboardCharts)
+	e.GET("/api/dashboard/counts", handleGetDashboardCounts)
 
 	e.GET("/api/subscribers/:id", handleGetSubscriber)
 	e.GET("/api/subscribers/:id/export", handleExportSubscriberData)

+ 3 - 0
init.go

@@ -87,10 +87,13 @@ func initFS(staticDir string) stuffbin.FileSystem {
 // initDB initializes the main DB connection pool and parse and loads the app's
 // SQL queries into a prepared query map.
 func initDB() *sqlx.DB {
+
 	var dbCfg dbConf
 	if err := ko.Unmarshal("db", &dbCfg); err != nil {
 		lo.Fatalf("error loading db config: %v", err)
 	}
+
+	lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
 	db, err := connectDB(dbCfg)
 	if err != nil {
 		lo.Fatalf("error connecting to DB: %v", err)

+ 2 - 1
queries.go

@@ -11,7 +11,8 @@ import (
 
 // Queries contains all prepared SQL queries.
 type Queries struct {
-	GetDashboardStats *sqlx.Stmt `query:"get-dashboard-stats"`
+	GetDashboardCharts *sqlx.Stmt `query:"get-dashboard-charts"`
+	GetDashboardCounts *sqlx.Stmt `query:"get-dashboard-counts"`
 
 	InsertSubscriber                *sqlx.Stmt `query:"insert-subscriber"`
 	UpsertSubscriber                *sqlx.Stmt `query:"upsert-subscriber"`

+ 30 - 21
queries.sql

@@ -678,21 +678,8 @@ INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
     RETURNING (SELECT url FROM link);
 
 
--- name: get-dashboard-stats
-WITH lists AS (
-    SELECT JSON_OBJECT_AGG(type, num) FROM (SELECT type, COUNT(id) AS num FROM lists GROUP BY type) row
-),
-subs AS (
-    SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM subscribers GROUP by status) row
-),
-orphans AS (
-    SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
-    WHERE subscriber_lists.subscriber_id IS NULL
-),
-camps AS (
-    SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM campaigns GROUP by status) row
-),
-clicks AS (
+-- name: get-dashboard-charts
+WITH clicks AS (
     -- Clicks by day for the last 3 months
     SELECT JSON_AGG(ROW_TO_JSON(row))
     FROM (SELECT COUNT(*) AS count, created_at::DATE as date
@@ -706,9 +693,31 @@ views AS (
           FROM campaign_views GROUP by date ORDER BY date DESC LIMIT 100
     ) row
 )
-SELECT JSON_BUILD_OBJECT('lists', COALESCE((SELECT * FROM lists), '[]'),
-                        'subscribers', COALESCE((SELECT * FROM subs), '[]'),
-                        'orphan_subscribers', (SELECT * FROM orphans),
-                        'campaigns', COALESCE((SELECT * FROM camps), '[]'),
-                        'link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
-                        'campaign_views', COALESCE((SELECT * FROM views), '[]')) AS stats;
+SELECT JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
+                        'campaign_views', COALESCE((SELECT * FROM views), '[]'));
+
+-- name: get-dashboard-counts
+SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
+                            'total', (SELECT COUNT(*) FROM subscribers),
+                            'blacklisted', (SELECT COUNT(*) FROM subscribers WHERE status='blacklisted'),
+                            'orphans', (
+                                SELECT COUNT(id) FROM subscribers
+                                LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
+                                WHERE subscriber_lists.subscriber_id IS NULL
+                            )
+                        ),
+                        'lists', JSON_BUILD_OBJECT(
+                            'total', (SELECT COUNT(*) FROM lists),
+                            'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
+                            'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
+                            'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
+                            'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
+                        ),
+                        'campaigns', JSON_BUILD_OBJECT(
+                            'total', (SELECT COUNT(*) FROM campaigns),
+                            'by_status', (
+                                SELECT JSON_OBJECT_AGG (status, num) FROM
+                                (SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
+                            )
+                        ),
+                        'messages', (SELECT SUM(sent) AS messages FROM campaigns));