Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d595f8c15 | ||
|
|
b1fc8e24d7 | ||
|
|
c5cf799f68 | ||
|
|
a5edc7b3b6 |
+10
-7
@@ -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
|
||||
links are stored by full URL so several links to the same domain remain
|
||||
distinct.
|
||||
- **Excluded**: back/forward (popstate) navigations, navigation involving
|
||||
the analytics page itself (`/_a`), and everything while the user has the
|
||||
editor open (`body.editing`). Admin noise, not visits.
|
||||
- **Excluded**: back/forward (popstate) navigations, navigating *to* the
|
||||
analytics page (`/_a` — its GET is untracked, and the server rejects it
|
||||
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,
|
||||
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
|
||||
logging in, that visit is removed from the JSON along with the counts
|
||||
recorded when it was created (site visit, entry view, entry transition).
|
||||
Views/transitions logged by later pings inside such a visit lack
|
||||
per-event timestamps and are left as-is. With no auth proxy (dev/test)
|
||||
logging in, that visit is removed from the JSON along with every count
|
||||
it recorded — an in-memory per-visit log of count events makes full
|
||||
reversal possible. With no auth proxy (dev/test)
|
||||
"admin" is everyone's state, so `hide` stays 0 and everything is recorded.
|
||||
- The server validates `to`: internal paths must be valid slug paths
|
||||
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
|
||||
|
||||
@@ -192,6 +192,10 @@ body {
|
||||
/* One line always: pagerite.js shrinks the font size to fit instead of
|
||||
wrapping (the themed size is the maximum). */
|
||||
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;
|
||||
padding-top: 1.5rem;
|
||||
color: var(--text);
|
||||
@@ -907,6 +911,30 @@ article h2 {
|
||||
(explicit img widths still shrink-wrap), while .wide keeps its full
|
||||
viewport bleed. */
|
||||
@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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -918,7 +946,11 @@ article h2 {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
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
|
||||
@@ -926,7 +958,7 @@ article h2 {
|
||||
#sidebar > ul {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.2rem;
|
||||
gap: 0.3rem 0.75rem;
|
||||
}
|
||||
|
||||
figure:has(.right),
|
||||
|
||||
@@ -385,9 +385,11 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// Reading time pauses after 1 minute of inactivity and resumes on the
|
||||
// next mouse/touch/scroll/keyboard event.
|
||||
// Excluded: back/forward (popstate never pings), everything while the
|
||||
// editor is open (body.editing — admin noise, not visits), and the
|
||||
// analytics page itself (/_a), even though fetch-navigation treats it
|
||||
// like a normal article.
|
||||
// editor is open (body.editing — admin noise, not visits), and
|
||||
// navigations TO the analytics page (/_a — admin machinery, and the
|
||||
// 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
|
||||
// everyone's state) ping normally but with hide=1: the server then
|
||||
// records nothing and scrubs any session the same browser accumulated
|
||||
@@ -395,7 +397,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// See docs/analytics.md.
|
||||
function ping(to, fr = currentPath, read = 0) {
|
||||
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 body = JSON.stringify({
|
||||
fr, to, hide,
|
||||
|
||||
+52
-27
@@ -316,6 +316,10 @@ class Store:
|
||||
pass # legacy schema / corrupt or unreadable file: start fresh
|
||||
#: client hash -> index of the current visit in data.visits
|
||||
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
|
||||
#: one, stashed for the visit the client's initial ping starts.
|
||||
#: Internal or absent referers never touch the table.
|
||||
@@ -403,35 +407,44 @@ class Store:
|
||||
del table[key]
|
||||
|
||||
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):
|
||||
the session is scrubbed from the stats. Views/transitions logged
|
||||
by later pings inside the visit lack per-event timestamps and are
|
||||
left as-is.
|
||||
the session is scrubbed from the stats. The in-memory
|
||||
``_count_log`` tracks each site-visit/view/transition count the
|
||||
visit produced, so the scrub reverses all of them — including the
|
||||
ones logged by later pings inside the visit.
|
||||
"""
|
||||
visit = self.data.visits[index]
|
||||
bucket = _bucket(visit.start)
|
||||
self._uncount(self.data.site_visits, bucket)
|
||||
views = self.data.views.get(visit.entry)
|
||||
if views is not None:
|
||||
self._uncount(views, bucket)
|
||||
if not views:
|
||||
del self.data.views[visit.entry]
|
||||
fr_map = self.data.transitions.get(visit.referer or "(direct)")
|
||||
if fr_map is not None:
|
||||
buckets = fr_map.get(visit.entry)
|
||||
if buckets is not None:
|
||||
self._uncount(buckets, bucket)
|
||||
if not buckets:
|
||||
del fr_map[visit.entry]
|
||||
if not fr_map:
|
||||
del self.data.transitions[visit.referer or "(direct)"]
|
||||
for event in self._count_log.pop(index, ()):
|
||||
kind = event[0]
|
||||
if kind == "site":
|
||||
self._uncount(self.data.site_visits, event[1])
|
||||
elif kind == "view":
|
||||
views = self.data.views.get(event[1])
|
||||
if views is not None:
|
||||
self._uncount(views, event[2])
|
||||
if not views:
|
||||
del self.data.views[event[1]]
|
||||
else: # transition
|
||||
_, fr, to, bucket = event
|
||||
fr_map = self.data.transitions.get(fr)
|
||||
if fr_map is not None:
|
||||
buckets = fr_map.get(to)
|
||||
if buckets is not None:
|
||||
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]
|
||||
# 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()):
|
||||
if i > index:
|
||||
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:
|
||||
"""Return the IP stored for ``client_hash``, or "" if missing."""
|
||||
@@ -590,10 +603,18 @@ class Store:
|
||||
)
|
||||
visit.statuses[entry] = status
|
||||
self.data.visits.append(visit)
|
||||
self.sessions[client_hash] = len(self.data.visits) - 1
|
||||
self._count(self.data.site_visits, _bucket(now))
|
||||
self._count(self.data.views.setdefault(entry, {}), _bucket(now))
|
||||
self._count_transition(referer or "(direct)", entry, now)
|
||||
index = len(self.data.visits) - 1
|
||||
self.sessions[client_hash] = index
|
||||
bucket = _bucket(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
|
||||
|
||||
def track_entry(
|
||||
@@ -763,9 +784,13 @@ class Store:
|
||||
else:
|
||||
visit = self.data.visits[index]
|
||||
now = datetime.now(UTC)
|
||||
bucket = _bucket(now)
|
||||
log = self._count_log.setdefault(index, [])
|
||||
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)
|
||||
log.append(("transition", fr, target, bucket))
|
||||
# First-seen only: repeat pages and repeated exits don't append.
|
||||
if visit.entry != target and target not in visit.trail:
|
||||
visit.trail.append(target)
|
||||
|
||||
@@ -27,15 +27,30 @@
|
||||
let my = 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()
|
||||
|
||||
// Convert viewport coordinates into the canvas' CSS-pixel coordinate
|
||||
// system. This remains correct with browser zoom, CSS transforms, etc.
|
||||
mx = (e.clientX - r.left) * c.clientWidth / r.width
|
||||
my = (e.clientY - r.top) * c.clientHeight / r.height
|
||||
mx = (x - r.left) * c.clientWidth / r.width
|
||||
my = (y - r.top) * c.clientHeight / r.height
|
||||
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 gy = 0.5
|
||||
|
||||
Reference in New Issue
Block a user