ソースを参照

Add campaign analytics APIs and UI

Kailash Nadh 3 年 前
コミット
3d0031b207

+ 90 - 14
cmd/campaigns.go

@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"github.com/gofrs/uuid"
+	"github.com/jmoiron/sqlx"
 	"github.com/knadh/listmonk/internal/subimporter"
 	"github.com/knadh/listmonk/models"
 	"github.com/labstack/echo"
@@ -49,6 +50,17 @@ type campaignContentReq struct {
 	To   string `json:"to"`
 }
 
+type campCountStats struct {
+	CampaignID int       `db:"campaign_id" json:"campaign_id"`
+	Count      int       `db:"count" json:"count"`
+	Timestamp  time.Time `db:"timestamp" json:"timestamp"`
+}
+
+type campTopLinks struct {
+	URL   string `db:"url" json:"url"`
+	Count int    `db:"count" json:"count"`
+}
+
 type campaignStats struct {
 	ID        int       `db:"id" json:"id"`
 	Status    string    `db:"status" json:"status"`
@@ -96,23 +108,11 @@ func handleGetCampaigns(c echo.Context) error {
 	if id > 0 {
 		single = true
 	}
-	if query != "" {
-		query = `%` +
-			string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%`
-	}
-
-	// Sort params.
-	if !strSliceContains(orderBy, campaignQuerySortFields) {
-		orderBy = "created_at"
-	}
-	if order != sortAsc && order != sortDesc {
-		order = sortDesc
-	}
 
-	stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order)
+	queryStr, stmt := makeCampaignQuery(query, orderBy, order, app.queries.QueryCampaigns)
 
 	// Unsafe to ignore scanning fields not present in models.Campaigns.
-	if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil {
+	if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), queryStr, pg.Offset, pg.Limit); err != nil {
 		app.log.Printf("error fetching campaigns: %v", err)
 		return echo.NewHTTPError(http.StatusInternalServerError,
 			app.i18n.Ts("globals.messages.errorFetching",
@@ -605,6 +605,64 @@ func handleTestCampaign(c echo.Context) error {
 	return c.JSON(http.StatusOK, okResp{true})
 }
 
+// handleGetCampaignViewAnalytics retrieves view counts for a campaign.
+func handleGetCampaignViewAnalytics(c echo.Context) error {
+	var (
+		app = c.Get("app").(*App)
+
+		typ  = c.Param("type")
+		from = c.QueryParams().Get("from")
+		to   = c.QueryParams().Get("to")
+	)
+
+	ids, err := parseStringIDs(c.Request().URL.Query()["id"])
+	if err != nil {
+		return echo.NewHTTPError(http.StatusBadRequest,
+			app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
+	}
+
+	if len(ids) == 0 {
+		return echo.NewHTTPError(http.StatusBadRequest,
+			app.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
+	}
+
+	// Pick campaign view counts or click counts.
+	var stmt *sqlx.Stmt
+	switch typ {
+	case "views":
+		stmt = app.queries.GetCampaignViewCounts
+	case "clicks":
+		stmt = app.queries.GetCampaignClickCounts
+	case "bounces":
+		stmt = app.queries.GetCampaignBounceCounts
+	case "links":
+		out := make([]campTopLinks, 0)
+		if err := app.queries.GetCampaignLinkCounts.Select(&out, pq.Int64Array(ids), from, to); err != nil {
+			app.log.Printf("error fetching campaign %s: %v", typ, err)
+			return echo.NewHTTPError(http.StatusInternalServerError,
+				app.i18n.Ts("globals.messages.errorFetching",
+					"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
+		}
+		return c.JSON(http.StatusOK, okResp{out})
+	default:
+		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
+	}
+
+	if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
+		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates"))
+	}
+
+	out := make([]campCountStats, 0)
+	if err := stmt.Select(&out, pq.Int64Array(ids), from, to); err != nil {
+		app.log.Printf("error fetching campaign %s: %v", typ, err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			app.i18n.Ts("globals.messages.errorFetching",
+				"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
+	}
+
+	return c.JSON(http.StatusOK, okResp{out})
+}
+
 // sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
 func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
 	if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
@@ -719,3 +777,21 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
 	o.Body = b.String()
 	return o, nil
 }
+
+// makeCampaignQuery cleans an optional campaign search string and prepares the
+// campaign SQL statement (string) and returns them.
+func makeCampaignQuery(q, orderBy, order, query string) (string, string) {
+	if q != "" {
+		q = `%` + string(regexFullTextQuery.ReplaceAll([]byte(q), []byte("&"))) + `%`
+	}
+
+	// Sort params.
+	if !strSliceContains(orderBy, campaignQuerySortFields) {
+		orderBy = "created_at"
+	}
+	if order != sortAsc && order != sortDesc {
+		order = sortDesc
+	}
+
+	return q, fmt.Sprintf(query, orderBy, order)
+}

+ 1 - 0
cmd/handlers.go

@@ -101,6 +101,7 @@ func registerHTTPHandlers(e *echo.Echo, app *App) {
 	g.GET("/api/campaigns", handleGetCampaigns)
 	g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
 	g.GET("/api/campaigns/:id", handleGetCampaigns)
+	g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics)
 	g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
 	g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
 	g.POST("/api/campaigns/:id/content", handleCampaignContent)

+ 4 - 0
cmd/queries.go

@@ -57,6 +57,10 @@ type Queries struct {
 	GetCampaignForPreview    *sqlx.Stmt `query:"get-campaign-for-preview"`
 	GetCampaignStats         *sqlx.Stmt `query:"get-campaign-stats"`
 	GetCampaignStatus        *sqlx.Stmt `query:"get-campaign-status"`
+	GetCampaignViewCounts    *sqlx.Stmt `query:"get-campaign-view-counts"`
+	GetCampaignClickCounts   *sqlx.Stmt `query:"get-campaign-click-counts"`
+	GetCampaignBounceCounts  *sqlx.Stmt `query:"get-campaign-bounce-counts"`
+	GetCampaignLinkCounts    *sqlx.Stmt `query:"get-campaign-link-counts"`
 	NextCampaigns            *sqlx.Stmt `query:"next-campaigns"`
 	NextCampaignSubscribers  *sqlx.Stmt `query:"next-campaign-subscribers"`
 	GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"`

+ 3 - 3
cmd/subscribers.go

@@ -409,7 +409,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
 		var req subQueryReq
 		if err := c.Bind(&req); err != nil {
 			return echo.NewHTTPError(http.StatusBadRequest,
-				app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
+				app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
 		}
 		if len(req.SubscriberIDs) == 0 {
 			return echo.NewHTTPError(http.StatusBadRequest,
@@ -449,7 +449,7 @@ func handleManageSubscriberLists(c echo.Context) error {
 	var req subQueryReq
 	if err := c.Bind(&req); err != nil {
 		return echo.NewHTTPError(http.StatusBadRequest,
-			app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
+			app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
 	}
 	if len(req.SubscriberIDs) == 0 {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
@@ -505,7 +505,7 @@ func handleDeleteSubscribers(c echo.Context) error {
 		i, err := parseStringIDs(c.Request().URL.Query()["id"])
 		if err != nil {
 			return echo.NewHTTPError(http.StatusBadRequest,
-				app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
+				app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
 		}
 		if len(i) == 0 {
 			return echo.NewHTTPError(http.StatusBadRequest,

+ 4 - 0
frontend/src/App.vue

@@ -80,6 +80,10 @@
                   <b-menu-item :to="{name: 'templates'}" tag="router-link"
                     :active="activeItem.templates" data-cy="templates"
                     icon="file-image-outline" :label="$t('globals.terms.templates')"></b-menu-item>
+
+                  <b-menu-item :to="{name: 'campaignAnalytics'}" tag="router-link"
+                    :active="activeItem.analytics" data-cy="analytics"
+                    icon="chart-bar" :label="$t('globals.terms.analytics')"></b-menu-item>
                 </b-menu-item><!-- campaigns -->
 
                 <b-menu-item :expanded="activeGroup.settings"

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

@@ -181,6 +181,18 @@ export const getCampaignStats = async () => http.get('/api/campaigns/running/sta
 export const createCampaign = async (data) => http.post('/api/campaigns', data,
   { loading: models.campaigns });
 
+export const getCampaignViewCounts = async (params) => http.get('/api/campaigns/analytics/views',
+  { params, loading: models.campaigns });
+
+export const getCampaignClickCounts = async (params) => http.get('/api/campaigns/analytics/clicks',
+  { params, loading: models.campaigns });
+
+export const getCampaignBounceCounts = async (params) => http.get('/api/campaigns/analytics/bounces',
+  { params, loading: models.campaigns });
+
+export const getCampaignLinkCounts = async (params) => http.get('/api/campaigns/analytics/links',
+  { params, loading: models.campaigns });
+
 export const convertCampaignContent = async (data) => http.post(`/api/campaigns/${data.id}/content`, data,
   { loading: models.campaigns });
 

+ 57 - 29
frontend/src/assets/style.scss

@@ -262,10 +262,17 @@ body.is-noscroll .b-sidebar {
     padding: 15px 10px;
     border-color: $grey-lightest;
   }
+
   .actions a, .actions .a {
     margin: 0 10px;
     display: inline-block;
   }
+  .actions a[data-disabled],
+  .actions .icon[data-disabled] {
+    pointer-events: none;
+    cursor: not-allowed;
+    color: $grey-light;
+  }
 }
 
 /* Modal */
@@ -294,16 +301,28 @@ body.is-noscroll .b-sidebar {
   }
 }
 
-.autocomplete .dropdown-content {
-  background-color: $white-ter;
+.autocomplete {
+  .dropdown-content {
+    background-color: $white-bis;
+  }
+  a.dropdown-item {
+    &:hover, &.is-hovered {
+      background-color: $grey-lightest;
+      color: $primary;
+    }
+  }
 }
 
 .input, .taginput .taginput-container.is-focusable, .textarea {
-  // box-shadow: inset 2px 2px 0px $white-ter;
   box-shadow: 2px 2px 0 $white-ter;
   border: 1px solid $grey-lighter;
 }
 
+.input {
+  height: auto;
+  padding: 10px;
+}
+
 /* Form fields */
 .field {
   &:not(:last-child) {
@@ -368,10 +387,10 @@ body.is-noscroll .b-sidebar {
   }
   &.public, &.running {
     $color: $primary;
-    color: $color;
+    color: lighten($color, 20%);;
     background: #e6f7ff;
-    border: 1px solid lighten($color, 37%);
-    box-shadow: 1px 1px 0 lighten($color, 25%);
+    border: 1px solid lighten($color, 42%);
+    box-shadow: 1px 1px 0 lighten($color, 42%);
   }
   &.finished, &.enabled {
     $color: $green;
@@ -491,25 +510,22 @@ section.import {
 /* Campaigns page */
 section.campaigns {
   table tbody {
+    .spinner {
+      margin-left: 10px;
+      .loading-overlay .loading-icon::after {
+        border-bottom-color: lighten(#1890ff, 30%);
+        border-left-color: lighten(#1890ff, 30%);
+      }
+    }
+
     tr.running {
       background: lighten(#1890ff, 43%);
       td {
         border-bottom: 1px solid lighten(#1890ff, 30%);
       }
-
-      .spinner {
-        margin-left: 10px;
-      }
-      .spinner .loading-overlay .loading-icon::after {
-        border-bottom-color: lighten(#1890ff, 30%);
-        border-left-color: lighten(#1890ff, 30%);
-      }
     }
 
     td {
-      &.status .spinner {
-        margin-left: 10px;
-      }
       .tags {
         margin-top: 5px;
       }
@@ -519,15 +535,8 @@ section.campaigns {
       }
 
       &.lists ul {
-        font-size: $size-7;
+        // font-size: $size-7;
         list-style-type: circle;
-
-        a {
-          color: $grey-dark;
-          &:hover {
-            color: $primary;
-          }
-        }
       }
 
       .fields {
@@ -555,6 +564,26 @@ section.campaigns {
   }
 }
 
+section.analytics {
+  .charts {
+    position: relative;
+    min-height: 100px;
+  }
+
+  .chart {
+    margin-bottom: 45px;
+  }
+
+  .donut-container {
+    position: relative;
+  }
+  .donut {
+    bottom: 0px;
+    right: 0px;
+    position: absolute !important;
+  }
+}
+
 /* Campaign / template preview popup */
 .preview {
   padding: 0;
@@ -702,11 +731,10 @@ section.campaign {
   }
 
   .c3-tooltip {
-    border: 0;
-    background-color: #fff;
+    @extend .box;
+    padding: 10px;
     empty-cells: show;
-    box-shadow: none;
-    opacity: 0.9;
+    opacity: 0.95;
 
     tr {
       border: 0;

+ 6 - 0
frontend/src/router/index.js

@@ -71,6 +71,12 @@ const routes = [
     meta: { title: 'Templates', group: 'campaigns' },
     component: () => import(/* webpackChunkName: "main" */ '../views/Templates.vue'),
   },
+  {
+    path: '/campaigns/analytics',
+    name: 'campaignAnalytics',
+    meta: { title: 'Campaign analytics', group: 'campaigns' },
+    component: () => import(/* webpackChunkName: "main" */ '../views/CampaignAnalytics.vue'),
+  },
   {
     path: '/campaigns/:id',
     name: 'campaign',

+ 17 - 0
frontend/src/utils.js

@@ -78,6 +78,23 @@ export default class Utils {
     return out.toFixed(2) + pfx;
   }
 
+  // Parse one or more numeric ids as query params and return as an array of ints.
+  parseQueryIDs = (ids) => {
+    if (!ids) {
+      return [];
+    }
+
+    if (typeof ids === 'string') {
+      return [parseInt(ids, 10)];
+    }
+
+    if (typeof ids === 'number') {
+      return [parseInt(ids, 10)];
+    }
+
+    return ids.map((id) => parseInt(id, 10));
+  }
+
   // https://stackoverflow.com/a/12034334
   escapeHTML = (html) => html.replace(/[&<>"'`=/]/g, (s) => htmlEntities[s]);
 

+ 427 - 0
frontend/src/views/CampaignAnalytics.vue

@@ -0,0 +1,427 @@
+<template>
+  <section class="analytics content relative">
+    <h1 class="title is-4">{{ $t('analytics.title') }}</h1>
+    <hr />
+
+    <form @submit.prevent="onSubmit">
+      <div class="columns">
+        <div class="column is-6">
+          <b-field :label="$t('globals.terms.campaigns')" label-position="on-border">
+            <b-taginput v-model="form.campaigns" :data="queriedCampaigns" name="campaigns" ellipsis
+              icon="tag-outline" :placeholder="$t('globals.terms.campaigns')"
+              autocomplete :allow-new="false" :before-adding="isCampaignSelected"
+              @typing="queryCampaigns" field="name" :loading="isSearchLoading"></b-taginput>
+          </b-field>
+        </div>
+
+        <div class="column is-5">
+          <div class="columns">
+            <div class="column is-6">
+              <b-field data-cy="from" :label="$t('analytics.fromDate')" label-position="on-border">
+                <b-datetimepicker
+                  v-model="form.from"
+                  icon="calendar-clock"
+                  :timepicker="{ hourFormat: '24' }"
+                  :datetime-formatter="formatDateTime" @input="onFromDateChange" />
+              </b-field>
+            </div>
+            <div class="column is-6">
+              <b-field data-cy="to" :label="$t('analytics.toDate')" label-position="on-border">
+                <b-datetimepicker
+                  v-model="form.to"
+                  icon="calendar-clock"
+                  :timepicker="{ hourFormat: '24' }"
+                  :datetime-formatter="formatDateTime" @input="onToDateChange" />
+              </b-field>
+            </div>
+          </div><!-- columns -->
+        </div><!-- columns -->
+
+        <div class="column is-1">
+          <b-button native-type="submit" type="is-primary" icon-left="magnify"
+            :disabled="form.campaigns.length === 0" data-cy="btn-search"></b-button>
+        </div>
+      </div><!-- columns -->
+    </form>
+
+    <section class="charts mt-5">
+      <div class="chart columns" v-for="(v, k) in charts" :key="k">
+        <div class="column is-9">
+          <b-loading v-if="v.loading" :active="v.loading" :is-full-page="false" />
+          <h4 v-if="v.chart !== null">{{ v.name }} ({{ counts[k] }})</h4>
+          <div :ref="`chart-${k}`" :id="`chart-${k}`"></div>
+        </div>
+        <div class="column is-2 donut-container">
+          <div :ref="`donut-${k}`" :id="`donut-${k}`" class="donut"></div>
+        </div>
+      </div>
+    </section>
+  </section>
+</template>
+
+<style lang="css">
+  @import "~c3/c3.css";
+</style>
+
+<script>
+import Vue from 'vue';
+import dayjs from 'dayjs';
+import c3 from 'c3';
+import { colors } from '../constants';
+
+const chartColorRed = '#ee7d5b';
+const chartColors = [
+  colors.primary,
+  '#FFB50D',
+  '#41AC9C',
+  chartColorRed,
+  '#7FC7BC',
+  '#3a82d6',
+  '#688ED9',
+  '#FFC43D',
+];
+
+export default Vue.extend({
+  data() {
+    return {
+      isSearchLoading: false,
+      queriedCampaigns: [],
+
+      // Data for each view.
+      counts: {
+        views: 0,
+        clicks: 0,
+        bounces: 0,
+        links: 0,
+      },
+      charts: {
+        views: {
+          name: this.$t('campaigns.views'),
+          data: [],
+          fn: this.$api.getCampaignViewCounts,
+          chart: null,
+          chartFn: this.processLines,
+          donut: null,
+          donutFn: this.renderDonutChart,
+          loading: false,
+        },
+
+        clicks: {
+          name: this.$t('campaigns.clicks'),
+          data: [],
+          fn: this.$api.getCampaignClickCounts,
+          chart: null,
+          chartFn: this.processLines,
+          donut: null,
+          donutFn: this.renderDonutChart,
+          loading: false,
+        },
+
+        bounces: {
+          name: this.$t('globals.terms.bounces'),
+          data: [],
+          fn: this.$api.getCampaignBounceCounts,
+          chart: null,
+          chartFn: this.processLines,
+          donut: null,
+          donutFn: this.renderDonutChart,
+          donutColor: chartColorRed,
+          loading: false,
+        },
+
+        links: {
+          name: this.$t('analytics.links'),
+          data: [],
+          chart: null,
+          loading: false,
+          fn: this.$api.getCampaignLinkCounts,
+          chartFn: this.renderLinksChart,
+        },
+      },
+
+      form: {
+        campaigns: [],
+        from: null,
+        to: null,
+      },
+    };
+  },
+
+  methods: {
+    formatDateTime(s) {
+      return dayjs(s).format('YYYY-MM-DD HH:mm');
+    },
+
+    isCampaignSelected(camp) {
+      return !this.form.campaigns.find(({ id }) => id === camp.id);
+    },
+
+    onFromDateChange() {
+      if (this.form.from > this.form.to) {
+        this.form.to = dayjs(this.form.from).add(7, 'day').toDate();
+      }
+    },
+
+    onToDateChange() {
+      if (this.form.from > this.form.to) {
+        this.form.from = dayjs(this.form.to).add(-7, 'day').toDate();
+      }
+    },
+
+    renderLineChart(typ, data, el) {
+      const conf = {
+        bindto: el,
+        unload: true,
+        data: {
+          type: 'spline',
+          xs: {},
+          columns: [],
+          names: [],
+          colors: {},
+          empty: { label: { text: this.$t('globals.messages.emptyState') } },
+        },
+        axis: {
+          x: {
+            type: 'timeseries',
+            tick: {
+              format: '%Y-%m-%d %H:%M',
+            },
+          },
+        },
+        legend: {
+          show: false,
+        },
+      };
+
+      // Add campaign data to the chart.
+      data.forEach((c, n) => {
+        if (c.data.length === 0) {
+          return;
+        }
+
+        const x = `x${n + 1}`;
+        const d = `data${n + 1}`;
+
+        // data1, data2, datan => x1, x2, xn.
+        conf.data.xs[d] = x;
+
+        // Campaign name for each datan.
+        conf.data.names[d] = c.name;
+
+        // Dates for each xn.
+        conf.data.columns.push([x, ...c.data.map((v) => dayjs(v.timestamp))]);
+
+        // Counts for each datan.
+        conf.data.columns.push([d, ...c.data.map((v) => v.count)]);
+
+        // Colours for each datan.
+        conf.data.colors[d] = chartColors[n % data.length];
+      });
+
+      this.$nextTick(() => {
+        if (this.charts[typ].chart) {
+          this.charts[typ].chart.destroy();
+        }
+
+        this.charts[typ].chart = c3.generate(conf);
+      });
+    },
+
+    renderDonutChart(typ, camps, data) {
+      const conf = {
+        bindto: this.$refs[`donut-${typ}`][0],
+        unload: true,
+        data: {
+          type: 'gauge',
+          columns: [],
+        },
+        gauge: {
+          width: 15,
+          max: 100,
+        },
+        color: {
+          pattern: [],
+        },
+      };
+
+      conf.gauge.max = camps.reduce((sum, c) => sum + c.sent, 0);
+      conf.data.columns.push([this.charts[typ].name, data.reduce((sum, d) => sum + d.count, 0)]);
+      conf.color.pattern.push(this.charts[typ].donutColor ?? chartColors[0]);
+
+      this.$nextTick(() => {
+        if (this.charts[typ].donut) {
+          this.charts[typ].donut.destroy();
+        }
+
+        this.charts[typ].donut = c3.generate(conf);
+      });
+    },
+
+    renderLinksChart(typ, camps, data) {
+      const conf = {
+        bindto: this.$refs[`chart-${typ}`][0],
+        unload: true,
+        data: {
+          type: 'bar',
+          x: 'x',
+          columns: [],
+          color: (c, d) => (typeof (d) === 'object' ? chartColors[d.index % data.length] : chartColors[0]),
+          empty: { label: { text: this.$t('globals.messages.emptyState') } },
+          onclick: (d) => {
+            window.open(data[d.index].url, '_blank', 'noopener noreferrer');
+          },
+        },
+        bar: {
+          width: {
+            max: 30,
+          },
+        },
+        axis: {
+          rotated: true,
+          x: {
+            type: 'category',
+            tick: {
+              multiline: false,
+            },
+          },
+        },
+      };
+
+      // Add link data to the chart.
+      // https://c3js.org/samples/axes_x_tick_rotate.html
+      conf.data.columns.push(['x', ...data.map((l) => {
+        try {
+          const u = new URL(l.url);
+          if (l.url.length > 80) {
+            return `${u.hostname}${u.pathname.substr(0, 50)}..`;
+          }
+          return u.hostname + u.pathname;
+        } catch {
+          return l.url;
+        }
+      })]);
+      conf.data.columns.push([this.$t('analytics.count'), ...data.map((l) => l.count)]);
+
+      this.$nextTick(() => {
+        if (this.charts[typ].chart) {
+          this.charts[typ].chart.destroy();
+        }
+        this.charts[typ].chart = c3.generate(conf);
+      });
+    },
+
+    processLines(typ, camps, data) {
+      // Make a campaign id => camp lookup map to group incoming
+      // data by campaigns.
+      const campIDs = camps.reduce((obj, c) => {
+        const out = { ...obj };
+        out[c.id] = c;
+        return out;
+      }, {});
+
+      // Group individual data points per campaign id.
+      // {1: [...], 2: [...]}
+      const groups = data.reduce((obj, d) => {
+        const out = { ...obj };
+        if (!(d.campaignId in out)) {
+          out[d.campaignId] = [];
+        }
+
+        out[d.campaignId].push(d);
+        return out;
+      }, {});
+
+      Object.keys(groups).forEach((k) => {
+        this.charts[typ].data.push({
+          name: campIDs[groups[k][0].campaignId].name,
+          data: groups[k],
+        });
+      });
+
+      this.$nextTick(() => {
+        this.renderLineChart(typ, this.charts[typ].data, this.$refs[`chart-${typ}`][0]);
+      });
+    },
+
+    onSubmit() {
+      // Fetch count for each analytics type (views, counts, bounces);
+      Object.keys(this.charts).forEach((k) => {
+        // Clear existing data.
+        this.charts[k].data = [];
+
+        // Fetch views, clicks, bounces for every campaign.
+        this.getData(k, this.form.campaigns);
+      });
+    },
+
+    queryCampaigns(q) {
+      this.isSearchLoading = true;
+      this.$api.getCampaigns({
+        query: q,
+        order_by: 'created_at',
+        order: 'DESC',
+      }).then((data) => {
+        this.isSearchLoading = false;
+        this.queriedCampaigns = data.results.map((c) => {
+          // Change the name to include the ID in the auto-suggest results.
+          const camp = c;
+          camp.name = `#${c.id}: ${c.name}`;
+          return camp;
+        });
+      });
+    },
+
+    getData(typ, camps) {
+      this.charts[typ].loading = true;
+
+      // Call the HTTP API.
+      this.charts[typ].fn({
+        id: camps.map((c) => c.id),
+        from: this.form.from,
+        to: this.form.to,
+      }).then((data) => {
+        // Set the total count.
+        this.counts[typ] = data.reduce((sum, d) => sum + d.count, 0);
+
+        this.charts[typ].chartFn(typ, camps, data);
+
+        if (this.charts[typ].donutFn) {
+          this.charts[typ].donutFn(typ, camps, data);
+        }
+        this.charts[typ].loading = false;
+      });
+    },
+  },
+
+  created() {
+    const now = dayjs().set('hour', 23).set('minute', 59).set('seconds', 0);
+    this.form.to = now.toDate();
+    this.form.from = now.subtract(7, 'day').set('hour', 0).set('minute', 0).toDate();
+  },
+
+  mounted() {
+    // Fetch one or more campaigns if there are ?id params, wait for the fetches
+    // to finish, add them to the campaign selector and submit the form.
+    const ids = this.$utils.parseQueryIDs(this.$route.query.id);
+    if (ids.length > 0) {
+      this.isSearchLoading = true;
+      Promise.allSettled(ids.map((id) => this.$api.getCampaign(id))).then((data) => {
+        data.forEach((d) => {
+          if (d.status !== 'fulfilled') {
+            return;
+          }
+
+          const camp = d.value;
+          camp.name = `#${camp.id}: ${camp.name}`;
+          this.form.campaigns.push(camp);
+        });
+
+        this.$nextTick(() => {
+          this.isSearchLoading = false;
+          this.onSubmit();
+        });
+      });
+    }
+  },
+});
+</script>

+ 29 - 12
frontend/src/views/Campaigns.vue

@@ -29,7 +29,7 @@
       paginated backend-pagination pagination-position="both" @page-change="onPageChange"
       :current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
       hoverable backend-sorting @sort="onSort">
-      <b-table-column v-slot="props" class="status" field="status"
+      <b-table-column v-slot="props" cell-class="status" field="status"
         :label="$t('globals.fields.status')" width="10%" sortable
         :td-attrs="$utils.tdID" header-class="cy-status">
         <div>
@@ -70,9 +70,9 @@
           </b-taglist>
         </div>
       </b-table-column>
-      <b-table-column v-slot="props" class="lists" field="lists"
+      <b-table-column v-slot="props" cell-class="lists" field="lists"
         :label="$t('globals.terms.lists')" width="15%">
-        <ul class="no">
+        <ul>
           <li v-for="l in props.row.lists" :key="l.id">
             <router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
               {{ l.name }}
@@ -103,7 +103,7 @@
         </div>
       </b-table-column>
 
-      <b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="18%">
+      <b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="15%">
         <div class="fields stats" :set="stats = getCampaignStats(props.row)">
           <p>
             <label>{{ $t('campaigns.views') }}</label>
@@ -140,8 +140,9 @@
         </div>
       </b-table-column>
 
-      <b-table-column v-slot="props" cell-class="actions" width="13%" align="right">
+      <b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
         <div>
+          <!-- start / pause / resume / scheduled -->
           <a href="" v-if="canStart(props.row)"
             @click.prevent="$utils.confirm(null,
               () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start">
@@ -170,6 +171,25 @@
               <b-icon icon="clock-start" size="is-small" />
             </b-tooltip>
           </a>
+
+          <!-- placeholder for finished campaigns -->
+          <a v-if="!canCancel(props.row)
+            && !canSchedule(props.row) && !canStart(props.row)" data-disabled>
+            <b-icon icon="rocket-launch-outline" size="is-small" />
+          </a>
+
+          <a href="" v-if="canCancel(props.row)"
+            @click.prevent="$utils.confirm(null,
+              () => changeCampaignStatus(props.row, 'cancelled'))"
+              data-cy="btn-cancel">
+            <b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
+              <b-icon icon="cancel" size="is-small" />
+            </b-tooltip>
+          </a>
+          <a v-else data-disabled>
+            <b-icon icon="cancel" size="is-small" />
+          </a>
+
           <a href="" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview">
             <b-tooltip :label="$t('campaigns.preview')" type="is-dark">
               <b-icon icon="file-find-outline" size="is-small" />
@@ -184,14 +204,11 @@
               <b-icon icon="file-multiple-outline" size="is-small" />
             </b-tooltip>
           </a>
-          <a href="" v-if="canCancel(props.row)"
-            @click.prevent="$utils.confirm(null,
-              () => changeCampaignStatus(props.row, 'cancelled'))"
-              data-cy="btn-cancel">
-            <b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
-              <b-icon icon="cancel" size="is-small" />
+          <router-link :to="{ name: 'campaignAnalytics', query: { 'id': props.row.id }}">
+            <b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
+              <b-icon icon="chart-bar" size="is-small" />
             </b-tooltip>
-          </a>
+          </router-link>
           <a href=""
             @click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }),
             () => deleteCampaign(props.row))" data-cy="btn-delete">

+ 1 - 1
i18n/cs-cz.json

@@ -427,7 +427,7 @@
     "subscribers.email": "E-mail",
     "subscribers.emailExists": "E-mail již existuje.",
     "subscribers.errorBlocklisting": "Chyba při uvádění odběratelů na seznam blokovaných: {error}",
-    "subscribers.errorInvalidIDs": "Uvedeno jedno nebo více neplatných ID: {error}",
+    "globals.messages.errorInvalidIDs": "Uvedeno jedno nebo více neplatných ID: {error}",
     "subscribers.errorNoIDs": "Nejsou uvedena žádná ID.",
     "subscribers.errorNoListsGiven": "Nejsou uvedeny žádné seznamy.",
     "subscribers.errorPreparingQuery": "Chyba při přípravě dotazu na odběratele: {error}",

+ 1 - 1
i18n/de.json

@@ -427,7 +427,7 @@
     "subscribers.email": "E-Mail",
     "subscribers.emailExists": "E-Mail existiert bereits.",
     "subscribers.errorBlocklisting": "Fehler. Abonnement ist geblockt: {error}",
-    "subscribers.errorInvalidIDs": "Eine oder mehrere IDs sind ungültig: {error}",
+    "globals.messages.errorInvalidIDs": "Eine oder mehrere IDs sind ungültig: {error}",
     "subscribers.errorNoIDs": "Keine IDs angegeben.",
     "subscribers.errorNoListsGiven": "Keine Listen angegeben.",
     "subscribers.errorPreparingQuery": "Fehler beim Vorbereiten der Abonnentenabfrage: {error}",

+ 9 - 1
i18n/en.json

@@ -1,6 +1,12 @@
 {
     "_.code": "en",
     "_.name": "English (en)",
+    "analytics.title": "Analytics",
+    "analytics.fromDate": "From",
+    "analytics.toDate": "To",
+    "analytics.count": "Count",
+    "analytics.invalidDates": "Invalid `from` or `to` dates.",
+    "analytics.links": "Links",
     "admin.errorMarshallingConfig": "Error marshalling config: {error}",
     "bounces.source": "Source",
     "bounces.unknownService": "Unknown service.",
@@ -160,6 +166,7 @@
     "globals.months.7": "Jul",
     "globals.months.8": "Aug",
     "globals.months.9": "Sep",
+    "globals.terms.analytics": "Analytics",
     "globals.terms.bounce": "Bounce | Bounces",
     "globals.terms.bounces": "Bounces",
     "globals.terms.campaign": "Campaign | Campaigns",
@@ -427,7 +434,8 @@
     "subscribers.email": "E-mail",
     "subscribers.emailExists": "E-mail already exists.",
     "subscribers.errorBlocklisting": "Error blocklisting subscribers: {error}",
-    "subscribers.errorInvalidIDs": "One or more invalid IDs given: {error}",
+    "globals.messages.errorInvalidIDs": "One or more IDs are invalid: {error}",
+    "globals.messages.missingFields": "Missing field(s): {name}",
     "subscribers.errorNoIDs": "No IDs given.",
     "subscribers.errorNoListsGiven": "No lists given.",
     "subscribers.errorPreparingQuery": "Error preparing subscriber query: {error}",

+ 1 - 1
i18n/es.json

@@ -427,7 +427,7 @@
     "subscribers.email": "Correo electrónico",
     "subscribers.emailExists": "El correo electrónico ya existe.",
     "subscribers.errorBlocklisting": "Error blocklisting subscriptrores: {error}",
-    "subscribers.errorInvalidIDs": "Uno o más IDs ingresados son inválidos: {error}",
+    "globals.messages.errorInvalidIDs": "Uno o más IDs ingresados son inválidos: {error}",
     "subscribers.errorNoIDs": "No se ingresaron IDs.",
     "subscribers.errorNoListsGiven": "No se ingresaron listas.",
     "subscribers.errorPreparingQuery": "Error preparando la consulta del subscriptor: {error}",

+ 1 - 1
i18n/fr.json

@@ -427,7 +427,7 @@
     "subscribers.email": "Email",
     "subscribers.emailExists": "Cet email existe déjà.",
     "subscribers.errorBlocklisting": "Erreur lors du blocage des abonné·es : {error}",
-    "subscribers.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}",
+    "globals.messages.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}",
     "subscribers.errorNoIDs": "Aucun identifiant fourni.",
     "subscribers.errorNoListsGiven": "Aucune liste attribuée.",
     "subscribers.errorPreparingQuery": "Erreur lors de la préparation de la requête d'abonné·e : {error}",

+ 1 - 1
i18n/it.json

@@ -427,7 +427,7 @@
     "subscribers.email": "Email",
     "subscribers.emailExists": "Email già esistente.",
     "subscribers.errorBlocklisting": "Errore durante il blocco degli iscritti: {error}",
-    "subscribers.errorInvalidIDs": "Una o più credenziali fornite non valide: {error}",
+    "globals.messages.errorInvalidIDs": "Una o più credenziali fornite non valide: {error}",
     "subscribers.errorNoIDs": "Nessun ID fornito.",
     "subscribers.errorNoListsGiven": "Nessuna lista fornita.",
     "subscribers.errorPreparingQuery": "Errore durante la preparazione della richiesta dell'iscritto: {error}",

+ 1 - 1
i18n/ml.json

@@ -427,7 +427,7 @@
     "subscribers.email": "ഇ-മെയിൽ",
     "subscribers.emailExists": "ഇ-മെയിൽ നേരത്തേതന്നെ ഉള്ളതാണ്",
     "subscribers.errorBlocklisting": "വരിക്കാരെ തടയുന്ന പട്ടികയിൽ പെടുത്തുന്നതിൽ പരാജയപ്പേട്ടു: {error}",
-    "subscribers.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
+    "globals.messages.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
     "subscribers.errorNoIDs": "ഐഡികളൊന്നും നൽകിയിട്ടില്ല",
     "subscribers.errorNoListsGiven": "ലിസ്റ്റുകളോന്നും നൽകിയിട്ടില്ല",
     "subscribers.errorPreparingQuery": "വരിക്കാരന്റെ ചോദ്യം തയാറാക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",

+ 1 - 1
i18n/pl.json

@@ -427,7 +427,7 @@
     "subscribers.email": "Email",
     "subscribers.emailExists": "Email już istnieje.",
     "subscribers.errorBlocklisting": "Błąd blokowania subskrybentów: {error}",
-    "subscribers.errorInvalidIDs": "Podano jeden lub więcej nieprawidłowy ID: {error}",
+    "globals.messages.errorInvalidIDs": "Podano jeden lub więcej nieprawidłowy ID: {error}",
     "subscribers.errorNoIDs": "Nie podano identyfikatorów.",
     "subscribers.errorNoListsGiven": "Nie podano list.",
     "subscribers.errorPreparingQuery": "Błąd przygotowywania zapytania o subskrypcje: {error}",

+ 1 - 1
i18n/pt-BR.json

@@ -427,7 +427,7 @@
     "subscribers.email": "E-mail",
     "subscribers.emailExists": "E-mail já existe.",
     "subscribers.errorBlocklisting": "Erro ao bloquear inscritos: {error}",
-    "subscribers.errorInvalidIDs": "Um ou mais IDs inválidos: {error}",
+    "globals.messages.errorInvalidIDs": "Um ou mais IDs inválidos: {error}",
     "subscribers.errorNoIDs": "Nenhum ID informado.",
     "subscribers.errorNoListsGiven": "Nenhuma lista informada.",
     "subscribers.errorPreparingQuery": "Erro ao preparar consulta de inscritos: {error}",

+ 1 - 1
i18n/pt.json

@@ -427,7 +427,7 @@
     "subscribers.email": "E-mail",
     "subscribers.emailExists": "E-mail já existe.",
     "subscribers.errorBlocklisting": "Erro ao bloquear subscritores: {error}",
-    "subscribers.errorInvalidIDs": "Foram dados um ou mais IDs inválidos: {error}",
+    "globals.messages.errorInvalidIDs": "Foram dados um ou mais IDs inválidos: {error}",
     "subscribers.errorNoIDs": "Não foram dados IDs.",
     "subscribers.errorNoListsGiven": "Não foram dadas listas.",
     "subscribers.errorPreparingQuery": "Erro ao preparar query dos subscritores: {error}",

+ 1 - 1
i18n/ru.json

@@ -427,7 +427,7 @@
     "subscribers.email": "E-mail",
     "subscribers.emailExists": "E-mail существует.",
     "subscribers.errorBlocklisting": "Ошибка блокировки подписчиков: {error}",
-    "subscribers.errorInvalidIDs": "Указан один или более неверных ID: {error}",
+    "globals.messages.errorInvalidIDs": "Указан один или более неверных ID: {error}",
     "subscribers.errorNoIDs": "Не указано ни одного ID.",
     "subscribers.errorNoListsGiven": "Не указано ни одного списка.",
     "subscribers.errorPreparingQuery": "Ошибка подготовки запроса подписчиков: {error}",

+ 1 - 1
i18n/tr.json

@@ -427,7 +427,7 @@
     "subscribers.email": "E-posta",
     "subscribers.emailExists": "E-posta zaten mevcut.",
     "subscribers.errorBlocklisting": "Hata, erişime engelli üyeleri gösterme: {error}",
-    "subscribers.errorInvalidIDs": "Bir yada daha fazla geçersiz ID: {error}",
+    "globals.messages.errorInvalidIDs": "Bir yada daha fazla geçersiz ID: {error}",
     "subscribers.errorNoIDs": "Herhangi bir ID verilmedi.",
     "subscribers.errorNoListsGiven": "Liste tanımı yapılmamış.",
     "subscribers.errorPreparingQuery": "Hata, üye sorgusu hazırlarken: {error}",

+ 37 - 0
queries.sql

@@ -544,6 +544,43 @@ u AS (
 )
 SELECT * FROM camps;
 
+-- name: get-campaign-view-counts
+WITH intval AS (
+    -- For intervals < a week, aggregate counts hourly, otherwise daily.
+    SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
+)
+SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
+    FROM campaign_views
+    WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
+    GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
+
+-- name: get-campaign-click-counts
+WITH intval AS (
+    -- For intervals < a week, aggregate counts hourly, otherwise daily.
+    SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
+)
+SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
+    FROM link_clicks
+    WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
+    GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
+
+-- name: get-campaign-bounce-counts
+WITH intval AS (
+    -- For intervals < a week, aggregate counts hourly, otherwise daily.
+    SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
+)
+SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
+    FROM bounces
+    WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
+    GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
+
+-- name: get-campaign-link-counts
+SELECT COUNT(*) AS "count", url
+    FROM link_clicks
+    LEFT JOIN links ON (link_clicks.link_id = links.id)
+    WHERE campaign_id=ANY($1) AND link_clicks.created_at >= $2 AND link_clicks.created_at <= $3
+    GROUP BY links.url ORDER BY "count" DESC LIMIT 50;
+
 -- name: next-campaign-subscribers
 -- Returns a batch of subscribers in a given campaign starting from the last checkpoint
 -- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means