Browse Source

Pro Dashboard

- added chartjs-adapter-moment for moment-based time scales/axes
- Pro Dashboard added
- dashboard service added
- getUserVisitStats to analyze link clicks for past 7 days w/cache
- getUserCountryStats to analyze Top 10 link click stats per country
- getUserCityStats to analyze Top 10 link click stats per city
pull/1/head
Rob Colbert 3 years ago
parent
commit
37503a0d50
  1. 58
      app/controllers/dashboard.js
  2. 8
      app/models/lib/resource-stats.js
  3. 2
      app/models/resource-view.js
  4. 247
      app/services/dashboard.js
  5. 25
      app/services/link.js
  6. 8
      app/services/resource.js
  7. 94
      app/views/dashboard/view.pug
  8. 3
      app/views/index-logged-in.pug
  9. 10
      app/views/link/components/list-item.pug
  10. 69
      client/js/site-app.js
  11. 7
      config/limiter.js
  12. 1
      lib/site-platform.js
  13. 1
      package.json
  14. 5
      yarn.lock

58
app/controllers/dashboard.js

@ -0,0 +1,58 @@
// dashboard.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'dashboard';
const express = require('express');
const { SiteController } = require('../../lib/site-lib');
class DashboardController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService, session: sessionService } = dtp.services;
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true });
const router = express.Router();
dtp.app.use('/dashboard', router);
router.use(async (req, res, next) => {
res.locals.currentView = DTP_COMPONENT_NAME;
return next();
});
router.get('/',
limiterService.create(limiterService.config.dashboard.getDashboardView),
authRequired,
this.getDashboardView.bind(this),
);
}
async getDashboardView (req, res, next) {
const { dashboard: dashboardService, link: linkService } = this.dtp.services;
try {
res.locals.userVisitStats = await dashboardService.getUserVisitStats(req.user);
res.locals.userCountryStats = await dashboardService.getUserCountryStats(req.user);
res.locals.userCityStats = await dashboardService.getUserCityStats(req.user);
res.locals.userLinks = await linkService.getForUser(req.user);
res.render('dashboard/view');
} catch (error) {
this.log.error('failed to display dashboard', { userId: req.user._id, error });
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new DashboardController(dtp);
return controller;
};

8
app/models/lib/resource-stats.js

@ -9,15 +9,11 @@ const mongoose = require('mongoose');
const Schema = mongoose.Schema;
module.exports.ResourceStats = new Schema({
uniqueVisitCount: { type: Number, default: 0, required: true },
totalVisitCount: { type: Number, default: 0, required: true },
upvoteCount: { type: Number, default: 0, required: true },
downvoteCount: { type: Number, default: 0, required: true },
commentCount: { type: Number, default: 0, required: true },
});
module.exports.ResourceStatsDefaults = {
uniqueVisitCount: 0,
totalVisitCount: 0,
upvoteCount: 0,
downvoteCount: 0,
commentCount: 0,
};

2
app/models/resource-view.js

@ -15,7 +15,7 @@ const ResourceViewSchema = new Schema({
resourceType: { type: String, enum: RESOURCE_TYPE_LIST, required: true },
resource: { type: Schema.ObjectId, required: true, index: 1, refPath: 'resourceType' },
uniqueKey: { type: String, required: true, index: 1 },
viewCount: { type: Number, default: 0, required: true },
visitCount: { type: Number, default: 0, required: true },
});
ResourceViewSchema.index({

247
app/services/dashboard.js

@ -0,0 +1,247 @@
// dashboard.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Link = mongoose.model('Link');
const LinkVisit = mongoose.model('LinkVisit');
const moment = require('moment');
const { SiteService } = require('../../lib/site-lib');
class DashboardService extends SiteService {
static get CACHE_ENABLED ( ) { return process.env.LINKS_DASHBOARD_CACHE === 'enabled'; }
constructor (dtp) {
super(dtp, module.exports);
}
async getUserVisitStats (user) {
const { cache: cacheService } = this.dtp.services;
let stats;
const cacheKey = `stats:user:${user._id}:visit`;
if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey);
if (stats) {
return stats;
}
}
this.log.info('getUserStats', 'generating user visit stats report', { userId: user._id });
const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
const links = await Link.find({ user: user._id }).lean();
const linkIds = links.map((link) => link._id);
stats = await LinkVisit.aggregate([
{
$match: {
link: { $in: linkIds },
$and: [
{ created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } },
],
},
},
{
$group: {
_id: {
year: { $year: '$created' },
month: { $month: '$created' },
day: { $dayOfMonth: '$created' },
hour: { $hour: '$created' },
},
count: { $sum: 1 },
},
},
{
$project: {
_id: false,
date: {
$dateFromParts: {
year: '$_id.year',
month: '$_id.month',
day: '$_id.day',
hour: '$_id.hour',
},
},
count: '$count',
},
},
{
$sort: { date: 1 },
},
]);
stats = await Link.populate(stats, [
{
path: 'link',
model: 'Link',
},
]);
const response = {
start: START_DATE,
end: END_DATE,
stats,
};
await cacheService.setObjectEx(cacheKey, 60 * 5, response);
return response;
}
async getUserCountryStats (user) {
const { cache: cacheService } = this.dtp.services;
let stats;
const cacheKey = `stats:user:${user._id}:country`;
if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey);
if (stats) {
return stats;
}
}
this.log.info('getUserCountryStats', 'generating user country stats report', { userId: user._id });
const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
const links = await Link.find({ user: user._id }).lean();
const linkIds = links.map((link) => link._id);
stats = await LinkVisit.aggregate([
{
$match: {
link: { $in: linkIds },
$and: [
{ created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } },
],
},
},
{
$group: {
_id: {
country: '$geoip.country',
},
count: { $sum: 1 },
},
},
{
$project: {
_id: false,
country: '$_id.country',
count: '$count',
},
},
{
$sort: { count: -1, country: 1 },
},
{
$limit: 10,
},
]);
stats = await Link.populate(stats, [
{
path: 'link',
model: 'Link',
},
]);
const response = {
start: START_DATE,
end: END_DATE,
stats,
};
await cacheService.setObjectEx(cacheKey, 60 * 5, response);
return response;
}
async getUserCityStats (user) {
const { cache: cacheService } = this.dtp.services;
let stats;
const cacheKey = `stats:user:${user._id}:city`;
if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey);
if (stats) {
return stats;
}
}
this.log.info('getUserCityStats', 'generating user city stats report', { userId: user._id });
const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
const links = await Link.find({ user: user._id }).lean();
const linkIds = links.map((link) => link._id);
stats = await LinkVisit.aggregate([
{
$match: {
link: { $in: linkIds },
$and: [
{ created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } },
],
},
},
{
$group: {
_id: {
country: '$geoip.country',
region: '$geoip.region',
city: '$geoip.city',
},
count: { $sum: 1 },
},
},
{
$project: {
_id: false,
country: '$_id.country',
region: '$_id.region',
city: '$_id.city',
count: '$count',
},
},
{
$sort: { count: -1, city: 1, region: 1, country: 1 },
},
{
$limit: 10,
},
]);
stats = await Link.populate(stats, [
{
path: 'link',
model: 'Link',
},
]);
const response = {
start: START_DATE,
end: END_DATE,
stats,
};
await cacheService.setObjectEx(cacheKey, 60 * 5, response);
return response;
}
}
module.exports = {
slug: 'dashboard',
name: 'dashboard',
create: (dtp) => { return new DashboardService(dtp); },
};

25
app/services/link.js

@ -77,12 +77,20 @@ class LinkService extends SiteService {
}
async getForUser (user, pagination) {
const links = await Link
.find({ user: user._id })
.sort({ order: 1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
let links;
if (pagination) {
links = await Link
.find({ user: user._id })
.sort({ order: 1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
} else {
links = await Link
.find({ user: user._id })
.sort({ order: 1 })
.lean();
}
return links;
}
@ -108,7 +116,8 @@ class LinkService extends SiteService {
/*
* We geo-analyze (but do not store) the IP address.
*/
const geo = geoip.lookup(req.ip);
const ipAddress = (req.ip === '127.0.0.1') ? '104.131.58.173' : req.ip;
const geo = geoip.lookup(ipAddress);
if (geo) {
visit.geoip = {
country: geo.country,
@ -126,6 +135,8 @@ class LinkService extends SiteService {
}
await visit.save();
await Link.updateOne({ _id: link._id }, { $inc: { 'stats.totalVisitCount': 1 } });
}
async setItemOrder (links) {

8
app/services/resource.js

@ -40,6 +40,7 @@ class ResourceService extends SiteService {
uniqueKey += `:user:${req.user._id.toString()}`;
}
const Model = mongoose.model(resourceType);
const response = await ResourceView.updateOne(
{
created: CURRENT_DAY,
@ -48,17 +49,16 @@ class ResourceService extends SiteService {
uniqueKey,
},
{
$inc: { viewCount: 1 },
$inc: { 'stats.visitCount': 1 },
},
{ upsert: true },
);
this.log.debug('resource view', { response });
if (response.upsertedCount > 0) {
const Model = mongoose.model(resourceType);
await Model.updateOne(
{ _id: resourceId },
{
$inc: { 'stats.totalViewCount': 1 },
$inc: { 'stats.uniqueVisitCount': 1 },
},
);
}

94
app/views/dashboard/view.pug

@ -0,0 +1,94 @@
extends ../layouts/main
block content
section.uk-section.uk-section-default
.uk-container.uk-container-expand
.uk-margin
h1 #{site.name} Dashboard #[small(class="uk-visible@m") for #{user.displayName || user.username}]
div(uk-grid).uk-grid-small
//-
//- Main Content Column
//-
div(class="uk-width-1-1 uk-width-2-3@m")
.uk-margin
.uk-card.uk-card-secondary.uk-card-small.uk-card-body
h4.uk-card-title Link Visits #[small.uk-text-muted past 7 days]
div(uk-grid).uk-grid-small
div(class="uk-width-1-1 uk-width-expand@m")
canvas(id="profile-stats", width="960", height="240")
div(class="uk-width-1-1 uk-width-auto@m")
.uk-margin
.uk-text-small start
.uk-text-bold= moment(userVisitStats.start).format('MMM DD, YYYY h:mm:ss a')
.uk-margin
.uk-text-small end
.uk-text-bold= moment(userVisitStats.end).format('MMM DD, YYYY h:mm:ss a')
.uk-margin
.uk-card.uk-card-secondary.uk-card-small
.uk-card-header
h2.uk-card-title Link Analytics #[small.uk-text-muted all-time]
.uk-card-body
ul.uk-list
each link in userLinks
li
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-2-3
.uk-text-lead
a(href=`/dashboard/link/${link._id}`).uk-text-truncate= link.label
.uk-width-1-6
.uk-text-small.uk-text-muted unique
div= formatCount(link.stats.uniqueVisitCount)
.uk-width-1-6
.uk-text-small.uk-text-muted total
div= formatCount(link.stats.totalVisitCount)
//-
//- Sidebar
//-
div(class="uk-width-1-1 uk-width-1-3@m")
.uk-margin
.uk-card.uk-card-secondary.uk-card-small.uk-card-body
h5.uk-card-title Top Countries
table.uk-table.uk-table-small
thead
tr
th Country
th Visits
tbody
each entry in userCountryStats.stats
tr
td.uk-table-expand= entry.country ? entry.country : '--'
td= entry.count
.uk-margin
.uk-card.uk-card-secondary.uk-card-small.uk-card-body
h5.uk-card-title Top Cities
table.uk-table.uk-table-small
thead
tr
th City
th(class="uk-visible@m") State
th Country
th Visits
tbody
each entry in userCityStats.stats
tr
td= entry.city ? entry.city : '--'
td(class="uk-visible@m")= entry.region ? entry.region : '--'
td= entry.country ? entry.country : '--'
td= entry.count
block viewjs
script(src="/chart.js/chart.min.js")
script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js")
script.
window.addEventListener('dtp-load', ( ) => {
dtp.app.renderProfileStats('canvas#profile-stats', !{JSON.stringify(userVisitStats.stats)});
});

3
app/views/index-logged-in.pug

@ -10,6 +10,9 @@ block content
div(uk-grid).uk-grid-small
.uk-width-expand
h3.uk-heading-bullet.uk-margin-small Your links
.uk-width-auto
a(href='/dashboard').uk-button.dtp-button-default.uk-button-small
+renderButtonIcon('fa-tachometer-alt', 'Dashboard')
.uk-width-auto
button(type="button", uk-toggle={ target: '#link-editor' }).uk-button.dtp-button-primary.uk-button-small
+renderButtonIcon('fa-plus', 'Add Link')

10
app/views/link/components/list-item.pug

@ -4,8 +4,15 @@ mixin renderLinksListItem (link)
.uk-width-auto
span
i.fas.fa-grip-lines
.uk-width-expand
a(href= link.href).uk-button.dtp-button-primary.uk-button-small.uk-border-rounded= link.label
.uk-width-auto
div(uk-grid).uk-grid-small.uk-flex-middle.uk-text-small.uk-text-muted
.uk-width-auto unique: #{formatCount(link.stats.uniqueVisitCount)}
.uk-width-auto total: #{formatCount(link.stats.totalVisitCount)}
.uk-width-auto
button(type="button", uk-toggle={ target: `#link-editor-${link._id}` }).uk-button.dtp-button-default.uk-button-small
span
@ -19,6 +26,7 @@ mixin renderLinksListItem (link)
).uk-button.dtp-button-danger.uk-button-small.uk-border-rounded
span
i.fas.fa-trash
div(id= `link-editor-${link._id}`, hidden).uk-margin
.uk-card.uk-card-secondary.uk-card-small.uk-card-body
+renderLinkEditor(`#link-editor-${link._id}`, link)
+renderLinkEditor(`#link-editor-${link._id}`, link)

69
client/js/site-app.js

@ -15,6 +15,13 @@ import Cropper from 'cropperjs';
import { EmojiButton } from '@joeattardi/emoji-button';
const GRID_COLOR = '#a0a0a0';
const GRID_TICK_COLOR = '#707070';
const AXIS_TICK_COLOR = '#c0c0c0';
const CHART_LINE_USER = 'rgb(0, 192, 0)';
export default class DtpSiteApp extends DtpApp {
constructor (user) {
@ -39,6 +46,8 @@ export default class DtpSiteApp extends DtpApp {
if (this.chat.input) {
this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this));
}
this.charts = {/* will hold rendered charts */};
}
async connect ( ) {
@ -564,6 +573,66 @@ export default class DtpSiteApp extends DtpApp {
UIkit.modal.alert(`Failed to delete link: ${error.message}`);
}
}
async renderProfileStats (selector, data) {
try {
const canvas = document.querySelector(selector);
const ctx = canvas.getContext('2d');
this.charts.profileStats = new Chart(ctx, {
type: 'line',
data: {
labels: data.map((item) => new Date(item.date)),
datasets: [
{
label: 'Link Visits',
data: data.map((item) => item.count),
borderColor: CHART_LINE_USER,
tension: 0.5,
},
],
},
options: {
scales: {
yAxis: {
display: true,
ticks: {
color: AXIS_TICK_COLOR,
callback: (value) => {
return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0');
},
},
grid: {
color: GRID_COLOR,
tickColor: GRID_TICK_COLOR,
},
},
x: {
type: 'time',
},
xAxis: {
display: false,
grid: {
color: GRID_COLOR,
tickColor: GRID_TICK_COLOR,
},
},
},
plugins: {
title: { display: false },
subtitle: { display: false },
legend: {
display: true,
position: 'bottom',
},
},
},
});
} catch (error) {
this.log.error('renderProfileStats', 'failed to render profile stats', { error });
UIkit.modal.alert(`Failed to render chart: ${error.message}`);
}
}
}
dtp.DtpSiteApp = DtpSiteApp;

7
config/limiter.js

@ -50,12 +50,7 @@ module.exports = {
* DashboardController
*/
dashboard: {
getEpisodeView: {
total: 15,
expire: ONE_MINUTE,
message: 'You are loading the dashboard episode view too quickly',
},
getHome: {
getDashboardView: {
total: 15,
expire: ONE_MINUTE,
message: 'You are loading the publisher dashboard too quickly',

1
lib/site-platform.js

@ -209,6 +209,7 @@ module.exports.startWebServer = async (dtp) => {
*/
module.app.use('/uikit', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'dist')));
module.app.use('/chart.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chart.js', 'dist')));
module.app.use('/chartjs-adapter-moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chartjs-adapter-moment', 'dist')));
module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist')));
module.app.use('/fontawesome', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', '@fortawesome', 'fontawesome-free')));
module.app.use('/moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'moment', 'min')));

1
package.json

@ -18,6 +18,7 @@
"argv": "^0.0.2",
"bull": "^4.1.1",
"chart.js": "^3.6.2",
"chartjs-adapter-moment": "^1.0.0",
"compression": "^1.7.4",
"connect-redis": "^6.0.0",
"cookie-parser": "^1.4.6",

5
yarn.lock

@ -2121,6 +2121,11 @@ chart.js@^3.6.2:
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.6.2.tgz#47342c551f688ffdda2cd53b534cb7e461ecec33"
integrity sha512-Xz7f/fgtVltfQYWq0zL1Xbv7N2inpG+B54p3D5FSvpCdy3sM+oZhbqa42eNuYXltaVvajgX5UpKCU2GeeJIgxg==
chartjs-adapter-moment@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.0.tgz#9174b1093c68bcfe285aff24f7388ad60d44e8f7"
integrity sha512-PqlerEvQcc5hZLQ/NQWgBxgVQ4TRdvkW3c/t+SUEQSj78ia3hgLkf2VZ2yGJtltNbEEFyYGm+cA6XXevodYvWA==
chokidar@^2.0.0:
version "2.1.8"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"

Loading…
Cancel
Save