Browse Source
- 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 citypull/1/head
14 changed files with 513 additions and 25 deletions
@ -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; |
||||
|
}; |
@ -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); }, |
||||
|
}; |
@ -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)}); |
||||
|
}); |
Loading…
Reference in new issue