Implement analytics feature

Add server-side visit analytics collection, a public-page ping endpoint,
and a full-screen AnalyticsView for admins.

Backend:
- Add pagerite/analytics.py: Analytics/Visit model, Store, and persistence
- Wire /_a ping endpoint and GET /_api/analytics into pagerite/app.py

Frontend:
- Add full-screen AnalyticsView with visitor charts and transition map
- Add VisitorCharts and TransitionGraph subcomponents
- Add analytics JS helpers in frontend/src/analytics/
- Send navigation pings from frontend/src/pagerite.js
- Mount AnalyticsView from frontend/src/main.js
- Document the feature in docs/analytics.md and update AGENTS.md
This commit is contained in:
2026-08-20 18:43:57 +00:00
parent 11f8de2df5
commit b4e8fad090
16 changed files with 1932 additions and 17 deletions
+333
View File
@@ -0,0 +1,333 @@
/**
* Chart geometry, smoothing, and SVG path generation for analytics charts.
*
* Fixed 720x180 viewBox, stretched to the panel width; values are per-unit
* rates (hour on the week view, day on month+).
*/
import { DAY, HOUR, WEEK, mondayUTC } from './time.js'
export const CHART_W = 720
export const CHART_H = 180
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.
*/
export function yScale(maxValue) {
let step = 1
outer: for (let exp = -3; exp < 8; exp++) {
for (const base of [1, 2, 5]) {
step = base * 10 ** exp
if (Math.ceil(maxValue / step) <= 5) break outer
}
}
let max = Math.ceil(maxValue / step) * step
if (max < 1) {
max = 1
step = 0.5
}
const minor = step >= 5 && step % 5 === 0 ? step / 5 : null
return { max, step, minor }
}
/**
* Edge-aware adaptive Gaussian smoothing. A change-point detector first
* finds traffic-level shifts (two-unit totals compared on both sides of
* each bucket; strong ratio + significance marks a candidate, and each run
* of candidates keeps only its best-scoring bucket as an edge). Each
* edge-delimited segment is then smoothed independently: a broad two-unit
* pilot estimates the local traffic rate, which ramps the Gaussian sigma
* from ~0.4 units (isolated events stay narrow, peaking at ~1 event/unit)
* up to 1 unit (busy traffic gets full smoothing), and every bucket spreads
* its count with its local sigma, clipped to the segment and renormalized
* so total visitor count is preserved exactly. The unit is one hour on the
* week view and one day on the month+ views, so the smoothing time scale
* follows the range (month+ sigmas are 24x the hourly ones). The raw series
* is drawn faintly behind the curve for reference. Operates on raw counts.
*/
export function smooth(counts, binMinutes, unitMinutes, {
minSigmaMinutes = unitMinutes / Math.sqrt(2 * Math.PI),
maxSigmaMinutes = unitMinutes,
pilotSigmaMinutes = 2 * unitMinutes,
detectorWindowMinutes = 2 * unitMinutes,
// Count thresholds are defined per hour and scale with the unit, so
// "low traffic" means the same thing on hourly and daily views
// (5-20 events/hour = 120-480/day on the month+ ranges).
highTrafficEvents = 10 * unitMinutes / 60,
minRatio = 2.5,
minSignificance = 4,
sigmaRampStart = 5 * unitMinutes / 60,
sigmaRampEnd = 20 * unitMinutes / 60,
} = {}) {
const n = counts.length
if (!n) return counts
const detectorWindowBins = Math.max(1, Math.round(detectorWindowMinutes / binMinutes))
const cumsum = new Float64Array(n + 1)
for (let i = 0; i < n; i++) cumsum[i + 1] = cumsum[i] + counts[i]
// Detect abrupt regime changes from aggregated traffic on both sides.
// Individual bins are deliberately ignored because even high traffic
// produces many 0-1 count bins at five-minute resolution.
const score = new Float64Array(n)
const candidate = new Uint8Array(n)
for (let i = detectorWindowBins; i < n - detectorWindowBins; i++) {
const left = cumsum[i] - cumsum[i - detectorWindowBins]
const right = cumsum[i + detectorWindowBins] - cumsum[i]
const high = Math.max(left, right)
const low = Math.min(left, right)
if (high < highTrafficEvents) continue
const ratio = (high + 1) / (low + 1)
const significance = (high - low) / Math.sqrt(high + low + 1)
if (ratio >= minRatio && significance >= minSignificance) {
candidate[i] = 1
score[i] = significance * Math.log(ratio)
}
}
// Collapse each continuous detector region to its strongest boundary.
const edges = []
for (let i = 0; i < n;) {
if (!candidate[i]) { i++; continue }
let j = i + 1
while (j < n && candidate[j]) j++
let best = i
for (let k = i + 1; k < j; k++) {
if (score[k] > score[best]) best = k
}
edges.push(best)
i = j
}
const reflectIndex = (i, length) => {
while (i < 0 || i >= length) {
i = i < 0 ? -i - 1 : 2 * length - i - 1
}
return i
}
const gaussianFilterReflect = (values, sigmaBins) => {
const length = values.length
const radius = Math.ceil(4 * sigmaBins)
const kernel = new Float64Array(radius * 2 + 1)
let sum = 0
for (let k = -radius; k <= radius; k++) {
const w = Math.exp(-0.5 * (k / sigmaBins) ** 2)
kernel[k + radius] = w
sum += w
}
for (let i = 0; i < kernel.length; i++) kernel[i] /= sum
const out = new Float64Array(length)
for (let i = 0; i < length; i++) {
let value = 0
for (let k = -radius; k <= radius; k++) {
value += values[reflectIndex(i + k, length)] * kernel[k + radius]
}
out[i] = value
}
return out
}
// Process each discontinuity-delimited regime independently so neither
// the pilot nor the final Gaussian can see through a detected boundary.
const bounds = [0, ...edges, n]
const smoothed = new Float64Array(n)
for (let b = 0; b < bounds.length - 1; b++) {
const lo = bounds[b]
const length = bounds[b + 1] - lo
const segment = counts.slice(lo, lo + length)
// Broad pilot estimates only the generic local traffic level used for
// choosing sigma; it is not the final displayed curve.
const pilot = gaussianFilterReflect(segment, pilotSigmaMinutes / binMinutes)
// Keep isolated/sparse traffic at the minimum bandwidth through
// sigmaRampStart events, then ramp toward maxSigmaMinutes (thresholds
// are per-hour rates scaled to the unit: low traffic is low traffic
// on every range).
const sigmaMinutes = new Float64Array(length)
for (let i = 0; i < length; i++) {
const ratePerUnit = pilot[i] * unitMinutes / binMinutes
let mix = (ratePerUnit - sigmaRampStart) / (sigmaRampEnd - sigmaRampStart)
mix = Math.sqrt(Math.max(0, Math.min(1, mix)))
sigmaMinutes[i] = minSigmaMinutes + mix * (maxSigmaMinutes - minSigmaMinutes)
}
// Each input bin spreads its own count using its local sigma. The
// per-bin kernel is renormalized after clipping to the segment,
// preserving total visitor count apart from floating-point error.
for (let j = 0; j < length; j++) {
const count = segment[j]
if (!count) continue
const sigmaBins = sigmaMinutes[j] / binMinutes
const radius = Math.ceil(4 * sigmaBins)
const start = Math.max(0, j - radius)
const end = Math.min(length, j + radius + 1)
let weightSum = 0
for (let i = start; i < end; i++) {
const d = i - j
weightSum += Math.exp(-0.5 * (d / sigmaBins) ** 2)
}
for (let i = start; i < end; i++) {
const d = i - j
smoothed[lo + i] += count * Math.exp(-0.5 * (d / sigmaBins) ** 2) / weightSum
}
}
}
return [...smoothed]
}
/**
* Catmull-Rom spline through the (smoothed) points, control points clamped
* to the plot area so the curve can never dip below zero or above the max.
*/
export function spline(pts) {
if (pts.length < 3) {
return `M${pts.map((p) => `${p.x},${p.y}`).join('L')}`
}
const clampY = (y) => Math.min(CHART_H, Math.max(PAD_TOP, y))
let d = `M${pts[0].x},${pts[0].y}`
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i]
const p1 = pts[i]
const p2 = pts[i + 1]
const p3 = pts[i + 2] || p2
const c1y = clampY(p1.y + (p2.y - p0.y) / 6)
const c2y = clampY(p2.y - (p3.y - p1.y) / 6)
d += `C${p1.x + (p2.x - p0.x) / 6},${c1y} `
+ `${p2.x - (p3.x - p1.x) / 6},${c2y} ${p2.x},${p2.y}`
}
return d
}
/** Build a full chart model from a series descriptor produced by time.js. */
export function buildChart(input) {
if (!input || !input.series.length) return null
const { series, t0, t1, rate, binMinutes, unitMinutes } = input
// Values are per-unit rates (hour on the week view, day on month+); the
// y max is derived from the *smoothed* curves so random single-bucket
// spikes don't blow up the scale. Smoothing works on raw counts (its edge
// detector thresholds are count-based), the result is scaled back to rates.
const smoothed = series.map((s) =>
smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate))
// Scale from the current/primary series only; older overlay weeks are drawn
// with the same scale and allowed to overflow if they are busier.
const highest = Math.max(0, ...smoothed[0])
const { max, step, minor } = yScale(highest)
const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
const drawn = series.map((s, si) => {
const pts = s.points.map((p, i) => ({ x: x(p.t), y: y(smoothed[si][i]) }))
const line = spline(pts)
const first = pts[0]
const last = pts.at(-1)
return {
...s,
line,
area: s.area ? `${line}L${last.x},${CHART_H}L${first.x},${CHART_H}Z` : null,
}
})
// Major (labeled) and minor (hairline) y grid ticks.
const majors = []
const minors = []
const nMajor = Math.round(max / step)
for (let k = 0; k <= nMajor; k++) {
const v = k * step
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
}
if (minor) {
for (let v = minor; v < max; v += minor) {
if (v % step !== 0) minors.push({ y: y(v) })
}
}
// X ticks. Week view: weekday labels centered at midday UTC, no vertical
// lines (day boundaries would be misleading in the viewer's timezone).
// Month view: likewise lineless, day numbers at noon UTC with the month
// name substituted for the 1st (marking the month change). Longer
// ranges: boundary lines at Mondays / months / years.
const isWeek = t1 - t0 === WEEK
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
const xticks = isWeek
? Array.from({ length: 7 }, (_, d) => {
const t = t0 + d * DAY + 12 * HOUR
return {
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
label: new Date(t).toLocaleDateString(undefined, {
weekday: 'short', timeZone: 'UTC',
}),
line: false,
}
})
: isMonth
? Array.from(
{ length: Math.floor((t1 - Math.ceil(t0 / DAY) * DAY) / DAY) },
(_, d) => {
const day = Math.ceil(t0 / DAY) * DAY + d * DAY
const date = new Date(day)
const t = day + 12 * HOUR
return {
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
label: date.getUTCDate() === 1
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
: String(date.getUTCDate()),
line: false,
}
},
)
: xticksFor(t0, t1).map((t) => ({
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
label: fmtTick(t, t1 - t0), line: true,
}))
return { max, majors, minors, series: drawn, xticks }
}
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
* boundaries up to a few years, then years. */
export function xticksFor(t0, t1) {
const span = t1 - t0
const ticks = []
if (span <= 100 * DAY) {
for (let t = mondayUTC(t0); t <= t1; t += WEEK) {
if (t >= t0) ticks.push(t)
}
return ticks
}
if (span <= 4 * 365 * DAY) {
const d = new Date(t0)
let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)
for (; t <= t1; ) {
ticks.push(t)
const m = new Date(t)
t = Date.UTC(m.getUTCFullYear(), m.getUTCMonth() + 1, 1)
}
return ticks
}
const d = new Date(t0)
for (let yr = d.getUTCFullYear() + 1; Date.UTC(yr, 0, 1) <= t1; yr++) {
ticks.push(Date.UTC(yr, 0, 1))
}
return ticks
}
export function fmtTick(t, span) {
const d = new Date(t)
if (span <= 100 * DAY) {
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
}
if (span <= 4 * 365 * DAY) {
return d.getUTCMonth() === 0
? d.toLocaleDateString(undefined, { year: 'numeric', timeZone: 'UTC' })
: d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
}
return d.toLocaleDateString(undefined, { year: 'numeric', timeZone: 'UTC' })
}
/** Y labels: integers when the step allows, one decimal for fractional steps. */
export function fmtY(v) {
return Number.isInteger(v) ? String(v) : v.toFixed(1)
}