diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 302ff39..8ab37e8 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { RANGES } from './analytics/time.js' import { + calcReadStats, calcTotalViews, copyIp, copyList, @@ -16,6 +17,7 @@ import { formatVisitRows, } from './analytics/format.js' import * as flagSvgs from 'country-flag-icons/string/3x2' +import TrailLink from './TrailLink.vue' import TransitionGraph from './TransitionGraph.vue' import VisitorCharts from './VisitorCharts.vue' @@ -78,6 +80,7 @@ onUnmounted(() => { const visits = computed(() => data.value?.visits || []) const totalViews = computed(() => calcTotalViews(data.value?.views)) +const readStats = computed(() => calcReadStats(visits.value)) const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week') @@ -126,6 +129,8 @@ function countryName(code) {
{{ formatCount(visits.length) }} visits
{{ formatCount(totalViews) }} page views
+
{{ readStats.avgMinPerVisit }} min/visit
+
{{ readStats.avgArticleMedianMin }} min article read
@@ -145,23 +150,9 @@ function countryName(code) { - + {{ v.utm }} - - {{ s.slug }} - +
@@ -205,11 +196,7 @@ function countryName(code) { - - {{ formatCount(s.count) }}×{{ s.slug }} - +
cancelAnimationFrame(rafId)) fill: var(--text); font-size: calc(11px / var(--node-r, 34)); text-anchor: middle; + filter: saturate(0); } .tmap a { cursor: pointer; } .tmap a:hover .tnodeslug { fill: var(--accent); } diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 3741c54..5a14c05 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -77,6 +77,44 @@ export function calcTotalViews(views) { return n } +// Very short reads are navigation/skims, not real reading time. +export const MIN_READ_SECONDS = 10 + +/** Average minutes per visit and average of per-article median read minutes. */ +export function calcReadStats(visits) { + const perArticle = {} + let totalVisitSeconds = 0 + let visitCount = 0 + for (const v of visits || []) { + const secs = Object.values(v.read || {}).filter((s) => s >= MIN_READ_SECONDS) + if (!secs.length) continue + visitCount++ + totalVisitSeconds += secs.reduce((a, b) => a + b, 0) + for (const [path, s] of Object.entries(v.read || {})) { + if (s >= MIN_READ_SECONDS) { + ;(perArticle[path] || (perArticle[path] = [])).push(s) + } + } + } + const avgMinPerVisit = visitCount + ? Math.max(1, Math.round(totalVisitSeconds / visitCount / 60)) + : 0 + + let articleMedianSum = 0 + const articleCount = Object.keys(perArticle).length + for (const arr of Object.values(perArticle)) { + arr.sort((a, b) => a - b) + const mid = Math.floor(arr.length / 2) + const median = arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2 + articleMedianSum += Math.max(MIN_READ_SECONDS, median) + } + const avgArticleMedianMin = articleCount + ? Math.max(1, Math.round(articleMedianSum / articleCount / 60)) + : 0 + + return { avgMinPerVisit, avgArticleMedianMin } +} + /** Build a path -> page title lookup from the site tree. */ function buildTitleMap(pageTree) { const titles = new Map() @@ -95,19 +133,19 @@ function slugOf(path) { return path === '/' ? '🏠' : path.split('/').pop() } -/** Host name of an external https origin, with scheme stripped. */ +/** Host name of an external https origin, with scheme and www. stripped. */ function externalSlug(origin) { try { - return new URL(origin).host + return new URL(origin).host.replace(/^www\./, '') } catch { - return origin.replace(/^https?:\/\//, '') + return origin.replace(/^https?:\/\//, '').replace(/^www\./, '') } } /** Format one trail step: an internal page or an external https origin. */ function stepOf(path, titles) { if (path?.startsWith('/')) { - return { path, slug: slugOf(path), title: titles.get(path) || '', external: false } + return { path, slug: slugOf(path), title: titles.get(path) || '', external: false, home: path === '/' } } if (path?.startsWith('https://')) { return { @@ -317,12 +355,7 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) { lastSeenLocal: formatWhenLocal(g.lastStart), pages: [...g.pages.entries()] .sort((a, b) => b[1] - a[1]) - .map(([path, count]) => ({ - path, - slug: slugOf(path), - title: titles.get(path) || '', - count, - })), + .map(([path, count]) => ({ ...stepOf(path, titles), count })), ip: g.ip, ipDisplay: hostIP(g.ip) || g.ip || '—', ua: g.ua, diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js index 51f94ab..d0677df 100644 --- a/frontend/src/analytics/transitions.js +++ b/frontend/src/analytics/transitions.js @@ -19,6 +19,8 @@ * pings) are skipped. */ +import { MIN_READ_SECONDS } from './format.js' + export const TNODE_R = 34 // node circles hold the slug and the view count export const EXT_R = 34 // external referer/exit nodes use the same full size @@ -85,13 +87,13 @@ function collectInternalTransitions(transitions) { return internal } -/** Domain-only label for an external origin (path removed). */ +/** Domain-only label for an external origin (path and www. removed). */ function extLabel(ext) { try { - const host = new URL(ext).hostname + const host = new URL(ext).hostname.replace(/^www\./, '') return host.length > 25 ? `${host.slice(0, 24)}…` : host } catch { - const s = ext.replace(/^https?:\/\//, '').split('/')[0] + const s = ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0] return s.length > 25 ? `${s.slice(0, 24)}…` : s } } @@ -186,7 +188,9 @@ function buildReadMinutes(visits) { const times = {} for (const v of visits || []) { for (const [path, sec] of Object.entries(v.read || {})) { - ;(times[path] || (times[path] = [])).push(sec) + if (sec >= MIN_READ_SECONDS) { + ;(times[path] || (times[path] = [])).push(sec) + } } } const minutes = {}