Compare commits

...
6 Commits
Author SHA1 Message Date
LeoVasanko 158b5f1961 Editor panel layout fixes, use body scroll and bidirectional sync with editor. 2026-08-24 21:41:33 +00:00
LeoVasanko f9ce0a4f3b Smarter initial analytics page when there is no visitor data yet. 2026-08-24 21:13:20 +00:00
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
12 changed files with 236 additions and 101 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
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
+1 -1
View File
@@ -45,7 +45,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
## Editing
- Editing happens **in place**, in two modes opened by two pens:
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the host sits inside `#content` (below the banner, never over the footer), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) straight into the visible article's heading and body.
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) straight into the visible article's heading and body.
- **Site mode** — the 🖊️ on the banner opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode.
- Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash.ext)` at the cursor.
+1 -1
View File
@@ -6,7 +6,7 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner):
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor scroll drives the article scroll — while any editor is open the window scroll is locked (`body.editing`), the panel exactly fills the available window height, and only `#main` scrolls; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked proportionally both ways, applied instantly (the window keeps scrolling normally while any editor is open the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`.
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row.
-1
View File
@@ -233,7 +233,6 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
</tbody>
</table>
</div>
<p v-else class="empty">no crawler hits recorded yet</p>
<div v-if="abuseRows.length" class="visit-table-wrap">
<table class="visit-table">
+36 -12
View File
@@ -4,9 +4,9 @@
// (/_api/ws/editor). Docked left of the article on the page itself.
// The socket connects when the editor is opened and reconnects with
// exponential backoff after a failure; unsaved text and pending saves
// survive a disconnect. Editor scroll drives the article scroll (while
// editing the window scroll is locked and only #main scrolls), keeping the
// rendered article at the cursor's position. Saving (💾 / Ctrl+S) is explicit
// survive a disconnect. Editor and article (window) scrolls are linked
// proportionally both ways (syncWindowToEditor / syncEditorToWindow).
// Saving (💾 / Ctrl+S) is explicit
// and refreshes the page regions in place — never a reload — so the editor
// state (unsaved text included) also survives closing the shell; it is lost
// only on a real page reload.
@@ -307,19 +307,37 @@ function onEditorShown() {
if (dirty.value) requestRender()
}
function syncScroll() {
// Editor scroll drives the article: keep the rendered page at the same
// proportional position as the cursor area in the editor. While editing
// the window scroll is locked and #main is the scrolling element.
// Bidirectional proportional scroll sync between the CodeMirror scroller
// and the window (the article's scroller, also while editing). Both
// directions apply instantly (never smooth — a smooth window scroll feeds
// its intermediate positions back into the editor and fights the user's
// scrolling) and coalesce to one update per frame. Loops are broken two
// ways: a one-frame driver flag, and a 1px tolerance so the scroll events
// caused by our own writes are no-ops. When the panel's height changes
// mid-scroll (its top tracks the banner), the page is the driver: the
// editor is re-matched to the page's position, never vice versa.
function syncWindowToEditor() {
if (syncingScroll || !view) return
const main = document.getElementById('main')
if (!main) return
syncingScroll = true
requestAnimationFrame(() => {
const scroller = view.scrollDOM
const max = scroller.scrollHeight - scroller.clientHeight
const pct = max > 0 ? scroller.scrollTop / max : 0
main.scrollTop = pct * (main.scrollHeight - main.clientHeight)
const y = pct * Math.max(0, document.documentElement.scrollHeight - innerHeight)
if (Math.abs(scrollY - y) > 1) scrollTo({ top: y, behavior: 'instant' })
syncingScroll = false
})
}
function syncEditorToWindow() {
if (syncingScroll || !view) return
syncingScroll = true
requestAnimationFrame(() => {
const scroller = view.scrollDOM
const pageMax = Math.max(0, document.documentElement.scrollHeight - innerHeight)
const pct = pageMax > 0 ? scrollY / pageMax : 0
const top = pct * Math.max(0, scroller.scrollHeight - scroller.clientHeight)
if (Math.abs(scroller.scrollTop - top) > 1) scroller.scrollTop = top
syncingScroll = false
})
}
@@ -379,7 +397,11 @@ onMounted(() => {
}),
parent: editorEl.value,
})
view.scrollDOM.addEventListener('scroll', syncScroll)
view.scrollDOM.addEventListener('scroll', syncWindowToEditor)
// Page → editor: window scroll (and resizes, e.g. the panel growing when
// the banner scrolls away) re-match the editor to the page's position.
addEventListener('scroll', syncEditorToWindow, { passive: true })
addEventListener('resize', syncEditorToWindow)
// Opening the editor means you want to write: start focused.
view.focus()
window.__pageritePageEditor = {
@@ -399,6 +421,8 @@ onUnmounted(() => {
}
view?.destroy()
delete window.__pageritePageEditor
removeEventListener('scroll', syncEditorToWindow)
removeEventListener('resize', syncEditorToWindow)
removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
@@ -606,7 +630,7 @@ onUnmounted(() => {
/* CodeMirror sits inside a bordered box, like a dialog's input area, with
a slight margin to the panel edges. Wheel scroll stays in the editor and
drives the article (syncScroll) instead of double-scrolling. */
drives the article (syncWindowToEditor) instead of double-scrolling. */
.editor {
flex: 1;
min-width: 0;
+2 -3
View File
@@ -68,8 +68,8 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
<template>
<section v-for="c in [
{ ylabel: 'visits', chart: visitChart, legend: true, empty: 'no visits recorded yet' },
{ ylabel: 'views', chart: viewChart, legend: false, empty: 'no views recorded yet' },
{ ylabel: 'visits', chart: visitChart, legend: true },
{ ylabel: 'views', chart: viewChart, legend: false },
]" :key="c.ylabel">
<template v-if="c.chart">
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
@@ -116,7 +116,6 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
</g>
</svg>
</template>
<p v-else class="empty">{{ c.empty }}</p>
</section>
</template>
+33 -4
View File
@@ -63,9 +63,24 @@ export function sumRange(raw, t0, t1) {
export function weeklySeries(buckets) {
const raw = rawTimes(buckets)
const times = Object.keys(raw).map(Number)
if (!times.length) return null
const now = Date.now()
const thisMonday = mondayUTC(now)
if (!times.length) {
const points = []
const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
for (let t = thisMonday; t < end; t += MIN5) {
points.push({ t, count: 0 })
}
return {
series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
t0: thisMonday,
t1: thisMonday + WEEK,
rate: HOUR / MIN5,
binMinutes: 5,
unitMinutes: 60,
unit: 'hour',
}
}
const oldest = Math.min(...times)
// Weeks back as far as the data reaches: difference in Monday indices.
const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1
@@ -113,13 +128,27 @@ export function weeklySeries(buckets) {
export function rollingSeries(buckets, rangeKey) {
const raw = rawTimes(buckets)
const times = Object.keys(raw).map(Number)
if (!times.length) return null
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
const t1 = Date.now()
const earliest = Math.min(...times)
const t0 = Math.floor((span != null
? t1 - span
: Math.min(earliest, t1 - minSpan)) / DAY) * DAY
: Math.min(times.length ? Math.min(...times) : Infinity, t1 - minSpan)) / DAY) * DAY
if (!times.length) {
const bucketMs = t1 - t0 <= 31 * DAY ? Math.min(bucket, 6 * HOUR) : bucket
const points = []
for (let t = t0; t < t1; t += bucketMs) {
points.push({ t, count: 0 })
}
return {
series: [{ points, label: '', opacity: 1, area: true }],
t0,
t1,
rate: DAY / bucketMs,
binMinutes: bucketMs / 60e3,
unitMinutes: 24 * 60,
unit: 'day',
}
}
// The bucket follows the actual window length, not the range key: when
// "all" is capped to its 30-day minimum it covers the very window "month"
// does, and daily bins would draw a different curve over the same data
+51 -36
View File
@@ -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);
@@ -265,10 +269,9 @@ body:has(.multicol) #content {
body.editing #content {
margin-left: var(--editor-w);
padding-left: 1rem;
/* gap between the docked editor and the content */
/* No overflow clipping here: .editor-host lives outside this box
(negative left), and .wide shrink-wraps to the remaining space. */
/* No padding/gap here: the article area starts flush at the panel's right
edge so that full-bleed .wide images (anchored to that edge below) line
up with it exactly. */
}
/* The sidebar's gutter space is needed by the editor instead. */
@@ -276,33 +279,17 @@ body.editing #sidebar {
display: none;
}
/* While editing, the window itself does not scroll: the editor panel is
exactly the remaining window height, and only the article area (#main)
scrolls. Without the editor, normal full-page scroll applies. */
body.editing {
height: 100vh;
overflow: hidden;
}
body.editing #content {
min-height: 0;
grid-template-rows: 100%;
}
body.editing #main {
height: 100%;
min-height: 0;
overflow-y: auto;
}
/* The editor host lives inside #content: it starts below the banner and
ends above the footer. With the page scroll locked while editing, the
panel exactly fills that space — the full available window height. */
/* While editing, the window keeps scrolling normally (the overlay
scrollbars take no layout space, so the vw-based .wide bleed stays exact
and no horizontal scrollbar appears). The editor panel is fixed to the
viewport's left edge; main.js sets its top each scroll frame — the
banner's bottom edge while the banner is visible, else the viewport top.
The panel scrolls internally. */
.editor-host {
position: absolute;
top: 0;
position: fixed;
top: 0; /* main.js: banner bottom while visible, else 0 */
bottom: 0;
left: calc(0px - var(--editor-w));
left: 0;
width: var(--editor-w);
}
@@ -822,18 +809,18 @@ body:has(.multicol) figure:has(.wide) {
margin-inline: calc(-20vw - 1.25rem) 0;
}
/* Editing: shrink the bleed to the space right of the docked editor. */
/* Editing: shrink the bleed to the space right of the docked editor. The
window keeps its overlay scrollbars while editing, so — unlike a classic
scrollbar — they take no layout space and the vw math stays exact. */
body.editing figure:has(.wide) {
width: calc(100vw - var(--editor-w));
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
}
/* Editing + multicol: the left gutter is 1/5 of the space right of the
editor, and the bleed also crosses #content's 1rem editing gap plus
main's 1.25rem left padding (the gutter shrink from the padding roughly
cancels the rounding): 2rem in total. */
editor, and the bleed also crosses main's 1.25rem left padding. */
body.editing:has(.multicol) figure:has(.wide) {
margin-inline: calc((100vw - var(--editor-w)) / -5 - 2rem) 0;
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
}
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
@@ -907,6 +894,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 +929,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 +941,7 @@ article h2 {
#sidebar > ul {
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem 1.2rem;
gap: 0.3rem 0.75rem;
}
figure:has(.right),
+25 -1
View File
@@ -23,6 +23,28 @@ let app = null
let savedTitle = null
let visible = false
// The panel is fixed to the viewport's left edge (pagerite.css) but tracks
// the page: its top is the banner's bottom edge while the banner is visible
// (= #content's top edge), and the viewport top once the banner has
// scrolled away. The window keeps scrolling normally while editing.
function trackPanelTop() {
const content = document.getElementById('content')
if (host && content) {
host.style.top = `${Math.max(0, content.getBoundingClientRect().top)}px`
}
}
function startTrackingPanel() {
trackPanelTop()
addEventListener('scroll', trackPanelTop, { passive: true })
addEventListener('resize', trackPanelTop)
}
function stopTrackingPanel() {
removeEventListener('scroll', trackPanelTop)
removeEventListener('resize', trackPanelTop)
}
export function openEditor(path, { mode = 'page' } = {}) {
if (app) {
// Shell already mounted: re-show it if hidden, switch to the requested
@@ -36,9 +58,9 @@ export function openEditor(path, { mode = 'page' } = {}) {
savedTitle = document.title
host = document.createElement('div')
host.className = 'editor-host'
// Docked inside #content: below the banner, next to the article only.
document.getElementById('content').prepend(host)
document.body.classList.add('editing')
startTrackingPanel()
// Which tab is active; pagerite.js uses this to decide whether a pen click
// closes the panel or switches tabs.
document.body.dataset.editorMode = mode
@@ -65,6 +87,7 @@ function showEditor() {
host.style.display = ''
host.firstElementChild?.classList.remove('closing')
document.body.classList.add('editing')
startTrackingPanel()
visible = true
dispatchEvent(new CustomEvent('pagerite:editor-shown'))
}
@@ -72,6 +95,7 @@ function showEditor() {
export function closeEditor() {
if (!visible) return
visible = false
stopTrackingPanel()
document.body.classList.remove('editing')
// dataset.editorMode is kept while hidden: the tabs use it to tell whether
// a pagerite:editor-shown event targets them.
+6 -4
View File
@@ -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
View File
@@ -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)
+19 -4
View File
@@ -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