analytics improvements:

- keep visitor charts y-axis minimum range at 10
- keep 'all' chart x-axis minimum span at 30 days
- group crawler hits by (ip, ua) and list top pages visited, show crawler page load counts as N× prefix
- store and display geoip city, keep geoip country overwrite
- stream live updates over WebSocket /_api/ws/analytics
- include family ring arcs in transition map crop bounds
- remove top UA summary, limit crawlers to 10 and visits to 20
- human-readable relative timestamps with UTC tooltip
This commit is contained in:
2026-08-21 01:36:58 +00:00
parent 242b62784c
commit deb5419c47
8 changed files with 333 additions and 76 deletions
+6 -6
View File
@@ -14,9 +14,9 @@ export const PAD_TOP = 14 // room above the highest point
/**
* Y always starts at 0; the max is a multiple of a 1-2-5 major step with at
* most 5 intervals, so labeled ticks are always round and evenly divided.
* Values are per-unit rates, so small scales are legitimate (a lone visit
* smoothes to well under 1/unit) — the floor is 1, not 10. Minor lines
* subdivide each major step in five when that yields integers.
* A minimum range of 10 keeps tiny near-zero values (e.g. a single visit)
* from being enlarged to a fractional scale; minor lines subdivide each
* major step in five when that yields integers.
*/
export function yScale(maxValue) {
let step = 1
@@ -27,9 +27,9 @@ export function yScale(maxValue) {
}
}
let max = Math.ceil(maxValue / step) * step
if (max < 1) {
max = 1
step = 0.5
if (max < 10) {
max = 10
step = 2
}
const minor = step >= 5 && step % 5 === 0 ? step / 5 : null
return { max, step, minor }
+107 -18
View File
@@ -62,6 +62,59 @@ function slugOf(path) {
return path === '/' ? '🏠' : path.split('/').pop()
}
/**
* Human-readable relative timestamp. Adapted from cista-storage: uses
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
* anything older than a week.
*/
export function formatWhen(ts, now = Date.now()) {
const date = new Date(ts)
const diff = date.getTime() - now
const adiff = Math.abs(diff)
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (adiff <= 5000) return 'now'
if (adiff <= 60000) {
return formatter
.format(Math.round(diff / 1000), 'second')
.replace(' ago', '')
.replaceAll(' ', '\u202F')
}
if (adiff <= 3600000) {
return formatter
.format(Math.round(diff / 60000), 'minute')
.replace('utes', '')
.replace('ute', '')
.replaceAll(' ', '\u202F')
}
if (adiff <= 86400000) {
return formatter
.format(Math.round(diff / 3600000), 'hour')
.replaceAll(' ', '\u202F')
}
if (adiff <= 604800000) {
return formatter
.format(Math.round(diff / 86400000), 'day')
.replaceAll(' ', '\u202F')
}
let d = date
.toLocaleDateString('en-ie', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})
.replace('Sept', 'Sep')
if (d.length === 14) d = d.replace(' ', ' \u2007')
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0')
d = d.slice(0, -4) + d.slice(-2)
return d
}
/** Full UTC timestamp for tooltips, e.g. "2026-08-21 00:20:48 UTC". */
export function formatWhenTooltip(ts) {
return new Date(ts).toISOString().replace('T', ' ').replace('Z', ' UTC')
}
/**
* Format recent visits for display, newest first. Each step is a linked slug
* pointing to its article; external referers/origins and direct entries are
@@ -133,31 +186,65 @@ export function countCrawlerUas(crawlers) {
}
/**
* Format raw crawler hit records as rows for a technical table. Missing
* values become "—".
* Group raw crawler hits by the same (ip, ua) pair we use to tell a real
* visitor from a crawler, and format each group as a row showing every
* internal page that crawler visited. Rows are sorted by total hits,
* most active crawler first, rather than by most recent hit.
*/
export function formatCrawlerRows(crawlers) {
const dash = (s) => (s || '—')
return [...(crawlers || [])].reverse().map((c) => ({
when: new Date(c.start).toLocaleString(),
entry: dash(c.entry),
ip: c.ip || '',
ipDisplay: c.host || hostIP(c.ip) || c.ip || '',
ua: c.ua_pretty || c.ua || '—',
uaRaw: c.ua || '',
referer: dash(c.referer),
query: dash(c.query),
}))
export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
const titles = buildTitleMap(pageTree)
const groups = new Map()
for (const c of crawlers || []) {
const key = `${c.ip}\0${c.ua}`
const g = groups.get(key) || {
ip: c.ip || '',
ua: c.ua_pretty || c.ua || '—',
uaRaw: c.ua || '',
lastStart: 0,
pages: new Map(),
}
const start = new Date(c.start).getTime()
if (start > g.lastStart) g.lastStart = start
if (c.entry?.startsWith('/')) {
g.pages.set(c.entry, (g.pages.get(c.entry) || 0) + 1)
}
groups.set(key, g)
}
const totalHits = (g) => {
let n = 0
for (const c of g.pages.values()) n += c
return n
}
return [...groups.values()]
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart)
.slice(0, 10)
.map((g) => ({
when: formatWhen(g.lastStart, now),
whenTooltip: formatWhenTooltip(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,
})),
ip: g.ip,
ipDisplay: hostIP(g.ip) || g.ip || '—',
ua: g.ua,
uaRaw: g.uaRaw,
total: totalHits(g),
}))
}
/**
* Format raw visit records as rows for a technical table. Returns objects
* with display strings; missing values become "—". ``trail`` joins page
* titles (when known) with " -> ".
* titles (when known) with " -> ". Only the 20 most recent visits are shown.
*/
export function formatVisitRows(visits, pageTree) {
export function formatVisitRows(visits, pageTree, now = Date.now()) {
const titles = buildTitleMap(pageTree)
return [...(visits || [])].reverse().map((v) => {
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
const trail = [v.entry, ...(v.trail || [])]
.filter((p) => p?.startsWith('/'))
.map((p) => ({
@@ -170,7 +257,8 @@ export function formatVisitRows(visits, pageTree) {
.join(', ')
const dash = (s) => (s || '—')
return {
when: new Date(v.start).toLocaleString(),
when: formatWhen(v.start, now),
whenTooltip: formatWhenTooltip(v.start),
trail,
referer: dash(v.referer),
ip: v.ip || '',
@@ -178,6 +266,7 @@ export function formatVisitRows(visits, pageTree) {
host: dash(v.host),
lang: dash(v.lang),
country: dash(v.country),
city: dash(v.city),
ua: v.ua_pretty || v.ua || '—',
uaRaw: v.ua || '',
utm: utm || '—',
+6 -3
View File
@@ -16,7 +16,7 @@ export const RANGES = {
week: { label: 'week' },
month: { label: 'month', span: 30 * DAY, bucket: 6 * HOUR },
year: { label: 'year', span: 365 * DAY, bucket: DAY },
all: { label: 'all', span: null, bucket: DAY },
all: { label: 'all', span: null, bucket: DAY, minSpan: 30 * DAY },
}
/** Monday 00:00 UTC of the week containing t (epoch day 0 was a Thursday). */
@@ -89,16 +89,19 @@ export function weeklySeries(buckets) {
/**
* Rolling window for the non-week ranges (x max = now), counts converted
* to per-day rates (the unit the month+ charts are read in).
* Ranges without a fixed span use the full data reach, but never less than
* their configured minSpan so the chart keeps a readable minimum x scale.
*/
export function rollingSeries(buckets, rangeKey) {
const raw = rawTimes(buckets)
const times = Object.keys(raw).map(Number)
if (!times.length) return null
const { span, bucket } = RANGES[rangeKey]
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
const t1 = Math.floor(Date.now() / bucket) * bucket + bucket
const earliest = Math.floor(Math.min(...times) / bucket) * bucket
const t0 = span != null
? t1 - span
: Math.floor(Math.min(...times) / bucket) * bucket
: Math.min(earliest, t1 - minSpan)
const points = []
for (let t = t0; t < t1; t += bucket) {
points.push({ t, count: sumRange(raw, t, t + bucket) })
+33 -2
View File
@@ -231,11 +231,33 @@ function buildFamilyArcs(nodes, radius) {
arcs.push({
d: `M ${Math.cos(a0) * r} ${Math.sin(a0) * r} `
+ `A ${r} ${r} 0 ${large} 1 ${Math.cos(a1) * r} ${Math.sin(a1) * r}`,
r,
a0,
a1,
})
}
return arcs
}
/** Bounding box of a circular arc centred at the origin, sampled. */
function arcBounds(r, a0, a1) {
let x0 = Infinity
let y0 = Infinity
let x1 = -Infinity
let y1 = -Infinity
const steps = 36
for (let i = 0; i <= steps; i++) {
const t = a0 + (a1 - a0) * (i / steps)
const x = Math.cos(t) * r
const y = Math.sin(t) * r
if (x < x0) x0 = x
if (y < y0) y0 = y
if (x > x1) x1 = x
if (y > y1) y1 = y
}
return { x0, y0, x1, y1 }
}
/** Collapse opposite transition directions into one unordered pair per page pair. */
function aggregatePairs(internal) {
const pairs = new Map() // unordered pair key -> [countAB, countBA]
@@ -584,8 +606,9 @@ export function buildTransitionGraph(data, pageTree) {
const pairs = aggregatePairs(internal)
const { edges, flows } = buildInternalEdges(pairs, byPath)
// Tight bounding box of the actual page nodes; internal edges and arcs
// stay within the node circles, so node bounds plus radius suffice.
// Tight bounding box of the actual page nodes; family ring arcs can sweep
// outside the node circle (e.g. a large arc between two siblings on the
// left side reaching around the right), so their geometry is included too.
// External nodes extend the box below.
const pad = 16
const xs = nodes.map((n) => n.x)
@@ -596,6 +619,14 @@ export function buildTransitionGraph(data, pageTree) {
x1: Math.max(...xs) + TNODE_R + pad,
y1: Math.max(...ys) + TNODE_R + pad,
}
for (const arc of arcs) {
if (arc.a0 == null) continue
const b = arcBounds(arc.r, arc.a0, arc.a1)
bounds.x0 = Math.min(bounds.x0, b.x0)
bounds.y0 = Math.min(bounds.y0, b.y0)
bounds.x1 = Math.max(bounds.x1, b.x1)
bounds.y1 = Math.max(bounds.y1, b.y1)
}
const ext = buildExternal(external, byPath, radius, bounds)
for (const xn of ext.extNodes) {