Compare commits

...
4 Commits
Author SHA1 Message Date
LeoVasanko 2d595f8c15 Reduce #brand to actual size, avoid clicks on empty banner space touching it. 2026-08-24 20:58:50 +00:00
LeoVasanko b1fc8e24d7 Eyes banner follows taps not just mouse. 2026-08-24 20:54:50 +00:00
LeoVasanko c5cf799f68 Better nav layout for portrait phones. 2026-08-24 20:43:18 +00:00
LeoVasanko a5edc7b3b6 Fix crawler misclassification from /_a and orphan counts on visit scrub
Two analytics corrections verified against the production capture:

- pagerite.js suppressed pings with fr == '/_a', but fetch-navigation
  away from the analytics page had already GET-ed the target without the
  preload header; the orphaned pending hit then flushed to the crawler
  list, classifying a real user as a crawler. Navigations away from /_a
  now ping normally (the server rejects /_a as a target regardless, and
  admin noise is already handled by hide=1).

- _remove_visit only reversed the visit's creation counts, leaving
  views/transitions from later pings behind as orphans on the graph with
  no matching row in the visitor table. An in-memory per-visit count log
  now tracks every count event, so an admin hide=1 scrub reverses the
  visit completely.
2026-08-24 20:18:13 +00:00
5 changed files with 121 additions and 44 deletions
+10 -7
View File
@@ -45,16 +45,19 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
back), so the exit URL is not necessarily the last trail entry. Outbound back), so the exit URL is not necessarily the last trail entry. Outbound
links are stored by full URL so several links to the same domain remain links are stored by full URL so several links to the same domain remain
distinct. distinct.
- **Excluded**: back/forward (popstate) navigations, navigation involving - **Excluded**: back/forward (popstate) navigations, navigating *to* the
the analytics page itself (`/_a`), and everything while the user has the analytics page (`/_a` — its GET is untracked, and the server rejects it
editor open (`body.editing`). Admin noise, not visits. as a ping target anyway), and everything while the user has the editor
open (`body.editing`). Admin noise, not visits. Navigating *away* from
`/_a` does ping: the fetch-navigation already GET-ed the target page
without the preload header, and without the ping that GET would flush to
the crawler list.
- **Admins**: when SSO is in use and the session is known to be an admin, - **Admins**: when SSO is in use and the session is known to be an admin,
the client still pings but adds `hide=1`. The server then records the client still pings but adds `hide=1`. The server then records
nothing — and if the same client session already had a visit from before nothing — and if the same client session already had a visit from before
logging in, that visit is removed from the JSON along with the counts logging in, that visit is removed from the JSON along with every count
recorded when it was created (site visit, entry view, entry transition). it recorded — an in-memory per-visit log of count events makes full
Views/transitions logged by later pings inside such a visit lack reversal possible. With no auth proxy (dev/test)
per-event timestamps and are left as-is. With no auth proxy (dev/test)
"admin" is everyone's state, so `hide` stays 0 and everything is recorded. "admin" is everyone's state, so `hide` stays 0 and everything is recorded.
- The server validates `to`: internal paths must be valid slug paths - The server validates `to`: internal paths must be valid slug paths
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the ("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
+34 -2
View File
@@ -192,6 +192,10 @@ body {
/* One line always: pagerite.js shrinks the font size to fit instead of /* One line always: pagerite.js shrinks the font size to fit instead of
wrapping (the themed size is the maximum). */ wrapping (the themed size is the maximum). */
white-space: nowrap; white-space: nowrap;
/* Shrink-wrap to the text: as a flex child of the column-direction
#banner it would otherwise stretch full-width, making the empty banner
area beside the text a link to the front page. */
align-self: flex-start;
margin: auto 1.25rem 0; margin: auto 1.25rem 0;
padding-top: 1.5rem; padding-top: 1.5rem;
color: var(--text); color: var(--text);
@@ -907,6 +911,30 @@ article h2 {
(explicit img widths still shrink-wrap), while .wide keeps its full (explicit img widths still shrink-wrap), while .wide keeps its full
viewport bleed. */ viewport bleed. */
@media (max-width: 48rem) { @media (max-width: 48rem) {
/* Nav type shrinks fluidly as space runs out. The nav font-size is
em-based both in base and in every theme override, so scaling the
banner's font-size (nothing else in the banner is em-sized — brand and
gaps use rem) reaches the nav through all themes with a single rule.
2.6vw crosses 1rem at ≈38.5rem, so only genuinely narrow viewports
shrink. */
#banner {
font-size: clamp(0.65rem, 2.6vw, 1rem);
}
/* Tighter margins/padding/gaps: the 1.25rem side gutter is wasted space
on a phone. */
#brand {
margin-inline: 0.6rem;
}
#nav {
padding: 0.25rem 0.6rem;
}
#nav ul {
gap: 0.15rem 0.9rem;
}
#content { #content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -918,7 +946,11 @@ article h2 {
max-height: none; max-height: none;
overflow-y: visible; overflow-y: visible;
border-radius: 0; border-radius: 0;
padding: 0.5rem 1rem; padding: 0.4rem 0.8rem;
/* Smaller type: the horizontal link strip fits roughly a third more
items per line. The nested-list gaps below are em-based and shrink
along. */
font-size: 0.8rem;
} }
/* Only the main level becomes a horizontal wrapping strip; submenus stay /* Only the main level becomes a horizontal wrapping strip; submenus stay
@@ -926,7 +958,7 @@ article h2 {
#sidebar > ul { #sidebar > ul {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem 1.2rem; gap: 0.3rem 0.75rem;
} }
figure:has(.right), figure:has(.right),
+6 -4
View File
@@ -385,9 +385,11 @@ import "overlayscrollbars/overlayscrollbars.css";
// Reading time pauses after 1 minute of inactivity and resumes on the // Reading time pauses after 1 minute of inactivity and resumes on the
// next mouse/touch/scroll/keyboard event. // next mouse/touch/scroll/keyboard event.
// Excluded: back/forward (popstate never pings), everything while the // Excluded: back/forward (popstate never pings), everything while the
// editor is open (body.editing — admin noise, not visits), and the // editor is open (body.editing — admin noise, not visits), and
// analytics page itself (/_a), even though fetch-navigation treats it // navigations TO the analytics page (/_a — admin machinery, and the
// like a normal article. // server rejects it as a ping target anyway). Navigations AWAY from /_a
// must ping: load() already fetched the target page without the preload
// header, and without the ping that GET would flush to the crawler list.
// Admins (when SSO is actually in use — with no auth proxy "admin" is // Admins (when SSO is actually in use — with no auth proxy "admin" is
// everyone's state) ping normally but with hide=1: the server then // everyone's state) ping normally but with hide=1: the server then
// records nothing and scrubs any session the same browser accumulated // records nothing and scrubs any session the same browser accumulated
@@ -395,7 +397,7 @@ import "overlayscrollbars/overlayscrollbars.css";
// See docs/analytics.md. // See docs/analytics.md.
function ping(to, fr = currentPath, read = 0) { function ping(to, fr = currentPath, read = 0) {
if (document.body.classList.contains("editing")) return; if (document.body.classList.contains("editing")) return;
if ((to && to === "/_a") || fr === "/_a") return; if (to && to === "/_a") return;
const hide = ssoAvailable && isAdmin ? 1 : 0; const hide = ssoAvailable && isAdmin ? 1 : 0;
const body = JSON.stringify({ const body = JSON.stringify({
fr, to, hide, fr, to, hide,
+52 -27
View File
@@ -316,6 +316,10 @@ class Store:
pass # legacy schema / corrupt or unreadable file: start fresh pass # legacy schema / corrupt or unreadable file: start fresh
#: client hash -> index of the current visit in data.visits #: client hash -> index of the current visit in data.visits
self.sessions: dict[bytes, int] = {} self.sessions: dict[bytes, int] = {}
#: visit index -> count events recorded for that visit, so
#: ``_remove_visit`` can reverse all of them — not just the ones
#: from the visit's creation. In-memory only, like ``sessions``.
self._count_log: dict[int, list[tuple]] = {}
#: ip -> external https origin of the latest document GET carrying #: ip -> external https origin of the latest document GET carrying
#: one, stashed for the visit the client's initial ping starts. #: one, stashed for the visit the client's initial ping starts.
#: Internal or absent referers never touch the table. #: Internal or absent referers never touch the table.
@@ -403,35 +407,44 @@ class Store:
del table[key] del table[key]
def _remove_visit(self, index: int) -> None: def _remove_visit(self, index: int) -> None:
"""Delete a visit and reverse the counts its creation recorded. """Delete a visit and reverse every count it recorded.
Used when a known visitor turns out to be an admin (hide=1 ping): Used when a known visitor turns out to be an admin (hide=1 ping):
the session is scrubbed from the stats. Views/transitions logged the session is scrubbed from the stats. The in-memory
by later pings inside the visit lack per-event timestamps and are ``_count_log`` tracks each site-visit/view/transition count the
left as-is. visit produced, so the scrub reverses all of them — including the
ones logged by later pings inside the visit.
""" """
visit = self.data.visits[index] for event in self._count_log.pop(index, ()):
bucket = _bucket(visit.start) kind = event[0]
self._uncount(self.data.site_visits, bucket) if kind == "site":
views = self.data.views.get(visit.entry) self._uncount(self.data.site_visits, event[1])
if views is not None: elif kind == "view":
self._uncount(views, bucket) views = self.data.views.get(event[1])
if not views: if views is not None:
del self.data.views[visit.entry] self._uncount(views, event[2])
fr_map = self.data.transitions.get(visit.referer or "(direct)") if not views:
if fr_map is not None: del self.data.views[event[1]]
buckets = fr_map.get(visit.entry) else: # transition
if buckets is not None: _, fr, to, bucket = event
self._uncount(buckets, bucket) fr_map = self.data.transitions.get(fr)
if not buckets: if fr_map is not None:
del fr_map[visit.entry] buckets = fr_map.get(to)
if not fr_map: if buckets is not None:
del self.data.transitions[visit.referer or "(direct)"] self._uncount(buckets, bucket)
if not buckets:
del fr_map[to]
if not fr_map:
del self.data.transitions[fr]
del self.data.visits[index] del self.data.visits[index]
# Sessions store list indices; shift the ones past the removed visit. # Sessions and count logs store list indices; shift the ones past
# the removed visit.
for key, i in list(self.sessions.items()): for key, i in list(self.sessions.items()):
if i > index: if i > index:
self.sessions[key] = i - 1 self.sessions[key] = i - 1
self._count_log = {
i - 1 if i > index else i: log for i, log in self._count_log.items()
}
def _client_ip(self, client_hash: bytes) -> str: def _client_ip(self, client_hash: bytes) -> str:
"""Return the IP stored for ``client_hash``, or "" if missing.""" """Return the IP stored for ``client_hash``, or "" if missing."""
@@ -590,10 +603,18 @@ class Store:
) )
visit.statuses[entry] = status visit.statuses[entry] = status
self.data.visits.append(visit) self.data.visits.append(visit)
self.sessions[client_hash] = len(self.data.visits) - 1 index = len(self.data.visits) - 1
self._count(self.data.site_visits, _bucket(now)) self.sessions[client_hash] = index
self._count(self.data.views.setdefault(entry, {}), _bucket(now)) bucket = _bucket(now)
self._count_transition(referer or "(direct)", entry, now) fr = referer or "(direct)"
self._count(self.data.site_visits, bucket)
self._count(self.data.views.setdefault(entry, {}), bucket)
self._count_transition(fr, entry, now)
self._count_log[index] = [
("site", bucket),
("view", entry, bucket),
("transition", fr, entry, bucket),
]
return visit return visit
def track_entry( def track_entry(
@@ -763,9 +784,13 @@ class Store:
else: else:
visit = self.data.visits[index] visit = self.data.visits[index]
now = datetime.now(UTC) now = datetime.now(UTC)
bucket = _bucket(now)
log = self._count_log.setdefault(index, [])
if target.startswith("/"): if target.startswith("/"):
self._count(self.data.views.setdefault(target, {}), _bucket(now)) self._count(self.data.views.setdefault(target, {}), bucket)
log.append(("view", target, bucket))
self._count_transition(fr, target, now) self._count_transition(fr, target, now)
log.append(("transition", fr, target, bucket))
# First-seen only: repeat pages and repeated exits don't append. # First-seen only: repeat pages and repeated exits don't append.
if visit.entry != target and target not in visit.trail: if visit.entry != target and target not in visit.trail:
visit.trail.append(target) visit.trail.append(target)
+19 -4
View File
@@ -27,15 +27,30 @@
let my = 0 let my = 0
let lastMove = 0 let lastMove = 0
addEventListener('mousemove', e => { // Mouse and touch tracked with separate listeners (pointer events arrive
// too late on some mobile browsers). Passive listeners: a drag on the
// banner still scrolls the page — on browsers that stop delivering
// touchmove once scrolling takes over, the gaze just follows until then.
const track = (x, y) => {
const r = c.getBoundingClientRect() const r = c.getBoundingClientRect()
// Convert viewport coordinates into the canvas' CSS-pixel coordinate // Convert viewport coordinates into the canvas' CSS-pixel coordinate
// system. This remains correct with browser zoom, CSS transforms, etc. // system. This remains correct with browser zoom, CSS transforms, etc.
mx = (e.clientX - r.left) * c.clientWidth / r.width mx = (x - r.left) * c.clientWidth / r.width
my = (e.clientY - r.top) * c.clientHeight / r.height my = (y - r.top) * c.clientHeight / r.height
lastMove = performance.now() lastMove = performance.now()
}) }
addEventListener('mousemove', e => track(e.clientX, e.clientY))
const trackTouch = e => {
const t = e.touches[0]
if (t) track(t.clientX, t.clientY)
}
addEventListener('touchstart', trackTouch, { passive: true })
addEventListener('touchmove', trackTouch, { passive: true })
let gx = 0.5 let gx = 0.5
let gy = 0.5 let gy = 0.5