=== UX Heatmap and Scrollmap by TheSEO ===
Contributors: theseo
Tags: heatmap, scrollmap, rage clicks, click tracking, privacy
Requires at least: 6.0
Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 2.1.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Click heatmaps, scroll depth, rage clicks and dead clicks inside WordPress. No cookies, no browser storage, no external service.

== Description ==

This plugin records what visitors do on the front end of your site and draws
it back over a live preview of the page. It answers four questions that a
page owner can act on the same afternoon:

* Where do people click, and is that where you wanted them to click?
* Where do people click on something that does not react at all?
* Where do people click over and over because they think it is broken?
* How far down the page does anybody actually get?

Everything runs inside your own WordPress. The tracker talks to your own site
and to nothing else. There is no account, no dashboard elsewhere, no third
party script and no cookie.

= The three overlays =

**Clicks.** A heat overlay over a live preview of the page. Warm is where
visitors clicked most, measured against the full height of the page rather
than against the part that happened to be on screen.

**Rage and dead.** Red rings are rage clicks, amber squares are dead clicks.
A rage click is one of three or more clicks inside one second that all land
within forty pixels of each other. A dead click is a click on something that
is not a link, a button or a form field and after which nothing on the page
changed within seven tenths of a second. Both are decided in the browser and
stored as a yes or no on the click that was already being recorded.

**Scroll depth.** The overlay fades where fewer page views reached, with a
solid line at the fold and a dotted line at the read line. A separate ruler
next to the preview shows the same thing in twenty bands of five percent, so
you can read the exact share that reached any depth.

= The fold and the read line =

The fold is where the first screen ends, taken per page view and reduced to
the median so that one odd browser window cannot move it. The read line is
the depth that half of the page views got past. If your call to action sits
below the read line, most visitors never see it. Both lines are drawn on the
scroll overlay and on the ruler, and both are named in plain language in the
conclusion.

= Segments =

Every number can be read per device, desktop against tablet against mobile,
and per starting point, page views that began on this page against page views
that followed a click from another page of your site. A table puts all five
segments side by side with page views, clicks, rage clicks, dead clicks and
the median depth, so a difference between phone and desktop is visible before
you go looking for it.

New against returning visitors is deliberately **not** offered. Recognising a
returning visitor needs a cookie or browser storage, this plugin uses neither,
and a number that cannot be measured honestly is better left out than
guessed. The viewer says so on screen instead of quietly leaving a gap.

= The conclusion, in plain sentences =

Under the numbers the viewer writes out what it sees, in ordinary language and
with the element named. For example: visitors click eleven times on
`div.pricing-card` while there is no link, button or form field there, and
nothing on the page changes. Or: half of the page views get no further than
forty two percent of the page height. Sentences only appear once there is
enough data to carry them, and when there is not, the viewer says that too.

= What it stores =

Per event, one row with:

* a random identifier that groups the events of a single page view and is
  thrown away when the page is closed
* the event type, click or scroll
* the page path, without the query string and without the fragment
* the click position, and the click position as a percentage of the full page
  height
* the viewport width and height, and the full page height
* the scroll depth as a percentage
* a short css selector of the clicked element
* two yes or no flags, one for a rage click and one for a dead click, plus the
  size of the rage burst on the first click of that burst
* a number that says whether the page view started on this page or followed a
  click from another page of this same site
* the server timestamp

= What it does not store =

* no cookie and nothing in localStorage or sessionStorage
* no IP address, not in plain text and not as a hash
* no user agent string
* no referring address; only the number that says internal or not
* no user id, and no link to a WordPress account
* no query string, so a name or an e-mail address in a url never reaches
  the table
* nothing that leaves your server

The identifier is new on every page view, so two visits by the same person are
two separate rows with nothing in common. That is the trade: you cannot follow
a person across pages, and in return there is nothing to follow.

= Do Not Track =

When a browser sends Do Not Track or Global Privacy Control, the tracker does
nothing at all. This is on by default and can be switched off.

= Retention =

A daily job deletes events older than the retention setting. You can choose 30,
90, 180 or 365 days, or keep everything. The default is 90 days. The settings
screen shows how many events are stored, how many page views they belong to,
the date of the oldest one and when the next cleanup runs.

There is a button to delete every collected event on the whole site, and a
button under the viewer to delete the events of one page only. Both ask for
confirmation first. Deactivating the plugin keeps the data and stops the job.
Deleting the plugin drops the table and the settings.

== Installation ==

1. Upload the plugin folder to `/wp-content/plugins/wp-ux-heatmap`, or upload
   the zip through Plugins, Add new, Upload plugin.
2. Activate the plugin.
3. Go to UX Heatmaps, Settings and switch Enable tracking on.
4. Visit your own site in a logged out browser to produce the first events.
5. Open UX Heatmaps and pick a page.

== Frequently Asked Questions ==

= Does it set cookies? =

No. It sets no cookie and writes nothing to browser storage. The identifier
that groups one page view lives in a JavaScript variable and disappears with
the page.

= Does anything leave my server? =

No. The plugin makes no outgoing http request. Every event goes to your own
admin-ajax.php and into your own database table.

= How exactly is a rage click decided? =

Three or more clicks within one second that all land within forty pixels of
each other, measured in page coordinates. The burst stays open while it keeps
growing and closes one second after the last click, so a burst of six clicks
is one burst and not two. Every click in the burst is marked, and the first
click of the burst also carries how many clicks were in it.

= How exactly is a dead click decided? =

A click is a candidate when the thing under it is not a link with an href, not
a button, input, select, textarea, label, summary or details, has no
interactive role or tabindex, and has no onclick or contenteditable. An
element that merely has cursor:pointer counts as a candidate on purpose,
because something that looks clickable and does nothing is exactly what this
is meant to find. The candidate is then watched for seven tenths of a second.
If the page did not change, the address did not change, the page did not
scroll, the height did not change, the focus did not move and no text got
selected, the click is stored as dead.

= Why is there no new against returning visitor split? =

Because it cannot be done honestly without a cookie or browser storage, and
this plugin uses neither. What you get instead is whether a page view started
on this page or followed a click from another page of your site, which is
measured from the referring address inside the browser without that address
ever being sent or stored.

= Why is there no nonce on the tracking endpoint? =

Because it would make tracking break silently while adding nothing. A nonce is
printed in the page, so on a site with full page caching every visitor gets the
same nonce and it stops working within a day. On an endpoint that anonymous
visitors must be able to reach, that nonce is not a security control either.
The endpoint is guarded by the master switch, a same origin check, a rate limit
of 600 events per address per minute, a cap of 30 events per request, a cap of
250 events per page view, and validation that only accepts a click or a scroll
on a url of this site with every number inside a fixed range. Every admin
action does use a nonce and a capability check: reading the events, deleting
one page, deleting everything and saving the settings.

= Does it slow the site down? =

The front end script is a single file of about 18 KB, loaded in the footer with
no dependencies, and it sends at most 250 events per page view in batches
rather than one request per event. It is not render blocking.

= Does it work with caching plugins? =

Yes. Nothing in the page markup expires, so a cached page keeps tracking. The
admin side is never cached by WordPress.

= The preview stays empty, what now? =

The viewer loads your page in a frame. If your site sends an X-Frame-Options or
a Content-Security-Policy header that forbids framing, the browser refuses and
the overlay has nothing to draw on. The viewer says so in that case, and every
number next to the preview is still correct.

= Does the plugin measure itself in the preview? =

No. The tracker stops immediately when it finds itself inside a frame, so
looking at a heatmap never adds events to it.

= Can I use it on client sites? =

Yes. The data stays in the client's own WordPress and the settings screen shows
exactly what is stored, so it can be shown to a client or to a data protection
officer.

== Changelog ==

= 2.1.0 =

New

* Rage click detection. Three or more clicks within one second inside forty
  pixels are marked as a burst, every click in it is flagged, and the first
  click carries the size of the burst.
* Dead click detection. A click on something that is not interactive and after
  which nothing on the page changed within seven tenths of a second is stored
  as a dead click. Decided with a MutationObserver plus checks on the address,
  the scroll position, the page height, the focus and the text selection.
* A third overlay that draws rage clicks as rings and dead clicks as squares
  at the spot where the visitor clicked.
* A scroll depth ruler beside the preview, twenty bands of five percent, with
  the fold and the read line drawn across it and labelled.
* The fold and the read line are now drawn on the scroll overlay as well, a
  solid line and a dotted line.
* Segmentation by starting point: whether a page view began on this page or
  followed a click from another page of this same site. Taken from the
  referring address inside the browser; the address itself is never sent and
  never stored, only the number.
* A table with all five segments side by side, desktop, tablet, mobile, landed
  here and came from another page, each with page views, clicks, rage clicks,
  dead clicks and the median depth.
* A conclusion per page in plain sentences, with the element named, that only
  states what the data carries and says so when it does not.
* The rage and dead click counts now travel with the page list, so the sidebar
  shows which page has a problem before you click into it.
* A button to delete the collected events of one page, next to the button that
  deletes everything.
* Retention is now a choice of 30, 90, 180 or 365 days or keep everything,
  instead of a fixed number.
* The settings screen now shows the number of page views the events belong to
  and when the next cleanup job runs.
* Dutch translation, plus a .pot file so the plugin can be translated. The
  sentences the viewer writes live in PHP and travel through the normal
  WordPress translation route rather than being hard coded in JavaScript.

Changed

* Events are sent in batches instead of one request per event. This is what
  makes both friction measurements possible, because a rage click is only a
  rage click once its burst has closed and a dead click is only a dead click
  once the page has had its moment to react. The rate limit therefore now
  counts events rather than requests, and went from 120 to 600 per address per
  minute, with a cap of 30 events per request.
* The tracker stops when it finds itself inside a frame, so opening the viewer
  no longer lets the preview record events of its own.
* The click throttle went from 250 to 30 milliseconds. A frustrated visitor
  clicks roughly every hundred milliseconds and the old value swallowed
  exactly the clicks the rage detection is looking for.
* The deepest point reached is recorded when the page is hidden or closed, so
  a visitor who reads to the bottom and leaves is counted at the bottom.
* Re-added the `Domain Path` header, because translations are bundled again.

= 2.0.0 =

Correctness

* Fixed the heatmap. The tracker measured the click position over the whole
  page and sent it, but there was no column for it, the endpoint never read it
  and the query never returned it. Every click was drawn at its position inside
  the viewport instead, which crushed the whole map into the top part of the
  page. Added the `click_y_percent` column, stored it and used it.
* Clicks recorded before this version have no page position. They are left out
  of the overlay and counted separately instead of being drawn in the wrong
  place.
* The Track scroll depth setting had no effect: the tracker was always told
  scrolling was on. It now follows the setting.
* The viewer loaded the oldest events instead of the most recent ones when the
  limit was reached. It now loads the newest.
* The overlay is drawn after the preview frame has loaded. Before, it could be
  drawn against an empty document and silently show nothing.
* Removed the random radius per click. The same data now always draws the same
  map.
* The overlay is drawn at a fixed internal resolution, so a long page no longer
  allocates a canvas of tens of megabytes.
* Scroll depth is reported as reach per page view, which is what the maps are
  read for, instead of a count of scroll events that always leaned towards the
  top of the page.
* The most clicked elements list is now filtered by device, like every other
  number on the screen, instead of mixing all devices into one list.
* The dead click test looked at the start of the selector string and therefore
  almost never matched. It now looks at the element that was actually clicked.
* The viewer no longer redraws the whole overlay on every scroll inside the
  preview.
* Removed the Enlarge heatmap handler. There was no button and no styling for
  it, so it could never run.

Privacy

* Removed the `wpuxh_sid` cookie. The plugin now sets no cookie at all, which
  is what the product page has always promised. The identifier that groups one
  page view is generated in the browser and never stored anywhere.
* Removed the `ip_hash` column. A salted hash of an IP address is still data
  about a person. The upgrade drops the column, which removes every value that
  was already in it.
* Removed the `user_agent` column, for the same reason and with the same
  upgrade step.
* The query string and the fragment are stripped from the page url before it is
  stored, so a token or an e-mail address in a url never reaches the table.
* Added Do Not Track and Global Privacy Control support, on by default.
* Added a retention setting, 90 days by default, with a daily job that deletes
  older events.
* Added a Delete all collected events button on the settings screen.
* Added a plain statement on the settings screen of what is stored, how many
  rows there are and what is never stored.

Security

* The tracking endpoint refused nothing but empty fields. It now accepts only
  the event types click and scroll, only a hexadecimal identifier, only a url
  on this site, and only numbers inside a fixed range. Every other request is
  rejected with a status code.
* Added a same origin check and a rate limit on the tracking endpoint. The
  address is used to build a transient key that expires within a minute and is
  never written to the events table.
* The page url in the viewer is now checked against the site host before it is
  put in the frame. Anyone who posted a foreign url to the tracking endpoint
  could previously get that url loaded in an administrator's browser.
* The element selector was concatenated into html in the admin viewer. All list
  content is now written as text nodes.
* Capped the stored selector at 191 characters and the stored url at 300, so a
  crafted request cannot write large blobs into the table.
* Replaced the reads of `$_COOKIE` that assumed a string, which threw a
  TypeError on PHP 8 when the value was an array.
* Every query goes through `$wpdb->prepare` with a fixed table name, including
  the two that previously ran unprepared because they happened to have no
  arguments.

Housekeeping

* Added the `Requires at least`, `Requires PHP` and `Tested up to` headers, and
  a guard that shows a notice instead of a fatal error on PHP below 7.4.
* Added `uninstall.php`. Deleting the plugin now drops the table and the
  options, on a single site and on every site of a multisite. Deactivating
  keeps the data and only stops the cron job.
* Added a schema version and an upgrade routine, so a change to the table
  reaches an existing install instead of only a fresh one.
* Moved the activation and deactivation hooks out of the constructor.
* Split the single 817 line file into a bootstrap plus six classes under
  `includes/`, each with one job.
* Rewrote this readme so it describes what the plugin does. The previous
  version listed rage click detection, density clusters, a drop-off overlay,
  a full screen heatmap and A/B test support. None of those existed in the
  code.

= 1.3.0 =
* Rewritten heatmap engine, device filtering, stats and insights.

= 1.2.0 =
* Device bars, expand button, selector grouping.

= 1.1.0 =
* Scroll tracking, top selectors, admin interface.

= 1.0.0 =
* First version: click tracking, heatmap viewer, page listing, scroll depth.

== Upgrade Notice ==

= 2.1.0 =
This version adds five columns to the events table for the rage click and dead
click flags, the burst size, the page height and the starting point of the page
view. That happens once, on the first admin page load after updating. Events
recorded before this version keep working and simply carry no friction flags,
so the rage and dead overlay stays empty until new events come in.

= 2.0.0 =
This version removes the `ip_hash` and `user_agent` columns from the events
table. That happens once, on the first admin page load after updating, and it
cannot be undone. Click data recorded before this version has no page position
and is left out of the overlay. Take a database backup first if the old rows
matter to you.
