=== NP Visitor Journeys ===
Contributors: norttipaikalle
Tags: analytics, privacy, statistics, referrers, utm
Requires at least: 5.8
Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 2.12.8
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Privacy-friendly page view and referrer analytics for WordPress. No cookies, no third-party tracking, no fingerprinting.

== Description ==

NP Visitor Journeys is a lightweight, self-hosted analytics plugin that tracks page views, referrers, outbound link clicks, and UTM campaign parameters directly in your WordPress database — with no external services involved.

**Key features**

* Page view and referrer tracking, including a "Visitor Journeys" view showing the path visitors take through your site.
* Outbound click tracking.
* UTM campaign parameter tracking (source, medium, campaign, term, content).
* Optional daily CSV export/backup of collected data.
* No cookies, no localStorage, no device fingerprinting, and no data sent to third-party analytics services — all data stays on your own server.
* IP address pseudonymisation (hashing) enabled by default.

= Privacy and data collection =

This plugin is designed to minimise the personal data it collects, but it does still process some personal data (primarily IP addresses) in order to function. See "Privacy and GDPR" under Frequently Asked Questions below for full details on what is collected, how it is stored, and how it can be removed.

== Installation ==

1. Upload the plugin files to the `/wp-content/plugins/np-visitor-journeys` directory, or install the plugin through the WordPress plugins screen directly.
2. Activate the plugin through the "Plugins" screen in WordPress.
3. Visit the "NP Visitor Journeys" menu in your WordPress admin to view stats and configure settings.

== Frequently Asked Questions ==

= What data does this plugin collect? =

For each page view: the page URL, page title, referrer URL, referrer type, UTM campaign parameters (if present in the query string), a timestamp, and the visitor's IP address. For each outbound link click: the destination URL and domain, a timestamp, and the visitor's IP address.

No cookies are set, no browser storage (localStorage/sessionStorage) is used, and no device or browser fingerprinting is performed.

= Is the IP address pseudonymised? =

By default (setting can be changed under NP Visitor Journeys > Settings), IP addresses are hashed with SHA-256 combined with a site-specific secret before being stored, so the raw IP address is never written to the database. This is more accurately described as **pseudonymisation** rather than full anonymisation: the same visitor will consistently produce the same hash on a given site (this is used deliberately to power the "Visitor Journeys" grouping feature), so under GDPR this hashed value is still treated as personal data. If you disable this setting, raw IP addresses are stored instead.

= How long is data kept? =

By default, tracked hits and clicks older than 14 months are deleted automatically (checked weekly). You can change this retention period, or set it to 0 to keep data forever, under NP Visitor Journeys > Settings > "Data Retention". You can also clear all data immediately at any time with "Clear All Stats" on the same page.

= What happens to my data if I delete the plugin? =

By default, all collected data is kept in the database if you delete the plugin, so you don't lose your historical stats by mistake. You can change this under NP Visitor Journeys > Settings ("Data on uninstall") to have the plugin remove its options only, or delete all collected data and options, when it is uninstalled.

= Can I erase data for a specific visitor if they request it? =

Yes. NP Visitor Journeys > Settings > "Erase Visitor by IP Address" lets an administrator delete all tracked hits and clicks for a given IP address — it matches both raw and pseudonymised (hashed) storage, so it works regardless of the current IP anonymisation setting. This plugin does not use WordPress core's built-in Tools > Export/Erase Personal Data screens, because those are keyed by email address and this plugin does not collect one — IP address is the only visitor identifier it stores.

= Does this plugin send data to any third party? =

No. The plugin makes no outbound network requests to any third-party service, and does not load any tracking scripts on your site's front end.

= Privacy and GDPR =

This plugin processes IP addresses (pseudonymised by default) and referrer/UTM data as part of its normal operation, which under GDPR constitutes processing of personal data. Site owners are responsible for ensuring their own privacy policy discloses this processing and for identifying an appropriate legal basis (commonly "legitimate interest" for first-party web analytics that does not use cookies or cross-site tracking). This plugin does not itself display a cookie-consent banner or gate tracking behind consent, since it does not use cookies.

== Screenshots ==

1. Main dashboard showing page views and top referrers.
2. Visitor journeys view.
3. Settings page.

== Changelog ==

= 2.12.8 =
* Improved deliverability: report emails now send with a proper From identity (your site name and admin email) instead of leaving it to WordPress's raw default, which is a well-known spam trigger. If your host has no SMTP plugin configured, emails still send directly from the server — Support tab now explains why that lands in spam and recommends a free SMTP plugin (the real fix). All email in this plugin has always gone through WordPress's own wp_mail(), so any SMTP plugin you already have configured was already being used automatically.

= 2.12.7 =
* Added: if the Recipient email field is left blank, reports (test, manual, and scheduled) now go to the site admin email instead of failing with "Set a recipient email first." The field's placeholder shows which address that is.

= 2.12.6 =
* Fixed: "Generate report now" did nothing (but still showed a success message) whenever the "Weekly AI report" toggle was off — it silently reused the same guard meant only to stop a stale scheduled run after disabling reporting. A manual click now always runs, regardless of the toggle.
* Fixed: after a successful "Generate report now", the page reloaded immediately, cutting off the confirmation toast before it was visible. Now waits briefly first.

= 2.12.5 =
* Added Documentation tab entries for the Reporting, AI Connection, and Support tabs, in plain language — what the weekly email tells you, how the "clicks I care about" (Goal) count works, and how to get an Anthropic account.

= 2.12.4 =
* Fixed: a report could fail with "Anthropic response was not valid JSON" — caused by the response being cut off before it finished generating (hitting the 1400-token limit), which breaks the JSON mid-string. Raised the limit to 2048 tokens, and a future cutoff now reports clearly ("hit the max_tokens limit") instead of the confusing generic JSON-parsing message.

= 2.12.3 =
* Moved the Support tab to the last position in the nav bar.

= 2.12.2 =
* Fixed: the Report prompt textarea (Reporting > AI Connection) didn't actually save — moving it into its own sub-tab in 2.12.0 left its card out of the autosave wiring, so edits were silently lost on navigating away.

= 2.12.1 =
* The default report prompt's ticket-purchase sentence no longer includes a "remove this sentence if..." meta-instruction — it read as confusing leftover text to anyone editing the prompt (and was also being sent to the AI as part of the prompt itself). The prompt still mentions ticket-buying clicks by default; delete the sentence yourself on Reporting > AI Connection if it doesn't apply to your site.

= 2.12.0 =
* Removed: the "Nörtti paikalle shared key" (LicMan-proxied) Report source — the weekly AI report now always uses your own Anthropic API key. If you'd switched to the shared key, set your own key on Reporting > AI Connection to keep reports running.
* Changed: Reporting tab now has Settings/AI Connection sub-tabs — schedule/recipient/run-now stay under Settings; the Anthropic API key and report prompt moved to their own AI Connection sub-tab.
* Added: Support tab, with a downloadable step-by-step guide to getting an Anthropic API key and a way to get in touch for help.
* Changed: the built-in report prompt's ticket-purchase framing moved from a fixed instruction into the editable default prompt (Reporting > AI Connection) — sites that don't sell tickets can remove that sentence; the report's "conversions" section itself is now phrased generically (tracked conversion goals, not specifically tickets).
* Fixed: row labels in this plugin's settings-style tables were vertically centered against tall multi-line fields (radio pairs, a textarea) instead of aligning to the top.

= 2.11.1 =
* Reporting's "Send test report now"/"Generate report now" buttons are no longer rate-limited on norttipaikalle.fi (or its subdomains) — every other site keeps the 3/hour client-side limit. Separate from, and has no effect on, any per-license usage cap the shared-key Report source enforces server-side.

= 2.11.0 =
* Added: the weekly AI report can now run on Nörtti paikalle's own shared Anthropic key by default (Report source: "Use Nörtti paikalle's shared key") — no API key setup required, capped to a modest per-license usage limit. Switch to "Use my own Anthropic API key" for unlimited/frequent reports.
* Added: the report's system prompt (tone/focus instructions) is now admin-editable, with a "Reset to default" button. The report's JSON structure/sections always stay fixed regardless of what's written there.

= 2.10.0 =
* Added: Reporting tab — a weekly, AI-compiled summary of your site's traffic (biggest sources, most interesting content, what's driving ticket-buying clicks, and what needs improving), emailed on a day/time you choose and archived in-app. Compiled by the Anthropic API using your own API key, which is validated live and stored encrypted in your database. Premium feature.
* Added: tracked selectors ("Track clicks on" in Settings > Tracking) can now be flagged as a conversion goal — the weekly report highlights what's driving clicks on goal-flagged selectors specifically.

= 2.9.1 =
* "Don't show visitors without a journey" now also hides bounce hits from the Hits chart, not just the Visitor Journeys table — the chart and table always agree on what's a bounce. Off by default; when on, the chart re-derives sessions for the selected date range on every load, which is real extra query work, not just a filter tweak. Total Views/Unique Visitors summary cards are unaffected.

= 2.9.0 =
* Added: "Don't show visitors without a journey" setting (Appearance card) — hides single-page visits with no outbound/tracked click from the Visitor Journeys table. Display-only: doesn't stop tracking or affect summary stats, and a hidden visit is still selectable in "Show only".
* Added: "Session Gap" setting (new card, default 120 minutes, was a fixed 30 minutes) — how long a visitor can go inactive before their next hit starts a new visit, and how long after their last page view an outbound/tracked click can still count as their exit. Applies to both the Visitor Journeys table and the Connections tab. Note: raising this from the old fixed 30 minutes will merge some visits that used to be counted as separate sessions.

= 2.8.36 =
* The Visitor Journeys table's "Approx time on site" now falls back to the visitor's exit-click timestamp for a single-page visit, instead of always showing 00:00:00 — if they clicked an outbound link or a tracked button before leaving, that's a real second timestamp to measure against, and the expanded row already used it to show a per-page duration; the table now agrees with it.

= 2.8.35 =
* Fixed (premium): the Shares card's data was sent to, and rendered by, unlicensed sites — unlike its sibling Top Referrers/UTM Campaigns cards, it wasn't gated behind license status either server- or client-side.
* Fixed (security): the Transfer tab's CSV export didn't defuse CSV/formula-injection characters in tracked page titles, referrers, and UTM values the way the daily-backup export already did — a crafted value could execute as a formula when the export was opened in Excel/Sheets. CSV import now reverses the same defusing, so an export/import round trip is unaffected.
* Fixed: entering a license key on the License tab saved it to the database before confirming it was actually valid — a mistyped or already-invalid key could overwrite a previously-working saved key. The key is now only saved once the license server has confirmed it's a real, recognized key.
* Fixed: the Visitor Journeys table's Source column icon and campaign badge (and the "Show only" filter's entry-domain options) read the pre-redirect page when a visit passed through a locale/SEO redirect, so a click-id added by the redirect itself (e.g. Facebook's ?fbclid) could be missed there even though the expanded row showed it correctly. Both views now agree.
* Fixed: rapid Prev/Next or filter clicks on the Dashboard or Connections tab could let an older, slower-to-respond request's data render after a newer one's, showing stale figures with no indication anything was wrong.
* Fixed: the Source column favicon fetch accepted a raw IP address as a "domain" to fetch from, which could be pointed at an internal or cloud-metadata address from an authenticated admin session. Raw IPs are now rejected outright.
* Fixed: entry-domain detection (used for "Direct" vs. external-referrer attribution) compared hostnames case-sensitively, so a differently-cased self-referral could be misattributed as external traffic.
* Fixed: two existing settings ("Show campaign badge value as text", "Ignore known crawlers") were missing from the Transfer tab's backup/restore, so a full restore silently reset them to defaults; the crawler-ignore setting was also left behind (never deleted) by a full "delete everything" uninstall. Both are fixed.

= 2.8.34 =
* Fixed: the browser-side pageview beacon (public.js's reportHit()) — meant as a backstop so pageviews are still recorded when a full-page cache (LiteSpeed, WP Rocket, etc.) serves a page without WordPress running — was fully built but never actually called, so it never fired. It now runs on every pageload; the existing IP+URL dedupe prevents double-counting on normal, non-cached pages where the server-side tracker already recorded the same hit.

= 2.8.33 =
* Fixed: the Visitor Journeys table's "Pages" count could show 2+ pages with an "Approx time on site" of 00:00:00 for a single visit that passed through a locale/SEO redirect (each redirect hop is recorded as its own hit a fraction of a second apart). The table now merges these back-to-back same-page redirect hits before counting pages, matching what expanding the row already showed.

= 2.8.32 =
* The "24 hrs" (hourly) view's rolling window now ends at the current whole hour instead of the exact moment the page loaded — chart labels and the drill-down point label always read a clean hour (e.g. "14:00") instead of showing the odd minute the page happened to load at.

= 2.8.31 =
* The Hits chart's day/week/month/custom breakdown, its year (12-month) breakdown, and the chart-point drill-down label now all bucket by your site's configured local timezone instead of UTC calendar days/months — fixes a mismatch where a clicked chart point could label itself with a different date than some of the visitor-journey rows shown underneath it (both were correct, just disagreeing on where a "day" starts). Note: because of this, a day/month's chart total for a given calendar date may now differ slightly from what it showed before this update, on any site not configured to the UTC timezone — no data was lost or changed, only which bucket it's counted in.

= 2.8.30 =
* Total Views, Unique Visitors, and the Visitor Journeys heading now show which date/hour they're narrowed to while a Hits-chart point is drilled into (e.g. "Total Views (24 Aug 2026)"), matching the existing "Showing 24 Aug 2026" chip under the chart — the underlying figures were already scoped to the clicked point, this just makes that visible without having to scroll back up to the chart.

= 2.8.29 =
* Widened the known-bot/crawler user-agent filter (which excludes matched hits from analytics reporting only — it never blocks a request from being served) to also cover AI/LLM crawlers (GPTBot, ClaudeBot, CCBot, Bytespider, Amazonbot, meta-externalagent, PetalBot), more SEO/scan bots (BLEXBot, DataForSEOBot, SeekportBot, Barkrowler), more scripted HTTP clients (PostmanRuntime, node-fetch, axios, Scrapy, libwww-perl, python-urllib), and monitoring tools (Nagios/check_http).

= 2.8.28 =
* "Track clicks on" rows can now be edited in place (pencil icon) instead of only remove-and-re-add.
* The Remove button on Track clicks on, Ignored referrers, and Blocked IPs rows is now a trashcan icon instead of a text button, for a more compact, consistent row action across all three tables.

= 2.8.27 =
* "Track clicks on" is now a Label + Trigger table instead of a plain textarea: each row has an optional Label (shown in the Visitor Journeys pill in place of the raw selector, e.g. "→ Newsletter signup" instead of "→ .buy-button") and a Trigger (the CSS selector, as before). Rows save instantly on Add/Remove, matching the adjacent Ignored referrers table — no more separate Save button. Existing selectors carry over automatically with a blank Label.

= 2.8.26 =
* Documentation tab: added an iframe section to the "Track clicks on" guide, alongside the existing Shadow DOM caveat — same-origin iframes are tracked normally, cross-origin iframes have the same hard limit as Shadow DOM.

= 2.8.25 =
* "Track clicks on" selectors now also match inside same-origin iframe embeds (e.g. third-party form widgets like Sender.net's), not just the main page.

= 2.8.24 =
* "Ignore known crawlers" card: the "Install NP Shield" sentence is now its own paragraph, separate from the "on by default" explanation.

= 2.8.23 =
* "Ignore known crawlers" card: the "Install NP Shield" text now links to https://norttipaikalle.fi/np-shield.

= 2.8.22 =
* The toast notification (used for every success confirmation — "Changes saved.", removing a blocked IP, clearing the icon cache, etc.) is now green instead of dark gray, so a successful action is easier to spot at a glance.

= 2.8.21 =
* Settings tab's autosave "Changes saved." toast now appears in the bottom-right corner instead of bottom-left.

= 2.8.20 =
* Settings tab's Tracking card: the "Save" button now sits in its own footer strip at the bottom-left of the card (inside the premium lock overlay when unlicensed), instead of sitting inline between the selectors textarea and the Ignored referrers table.

= 2.8.19 =
* New "Ignore known crawlers" card under Settings. Without NP Shield installed, it's a tickbox (on by default) exposing the plugin's existing bot/crawler filtering. With NP Shield active, it becomes a read-only mirror of NP Shield's own "Trusted crawlers" list, and hit tracking now also honours that list.

= 2.8.18 =
* The Settings tab's Appearance, Privacy, and Data Retention & Backup cards now save each field automatically as soon as you change it (no more "Save Settings" button or full page reload) — a "Changes saved." confirmation appears in the bottom-right corner. The Tracking card's selectors keep their own "Save" button, since that's free text rather than a toggle.

= 2.8.17 =
* Hovering an entry node in the Journeys tab's Cloud view now also shows the referring website and/or UTM campaign data behind it, when known, not just the node's own label.

= 2.8.16 =
* Renamed the "Connections" tab to "Journeys" (nav tab label, premium-lock teaser, and Documentation/License tab copy). The URL's `tab=connections` value and internal element/function names are unchanged.

= 2.8.15 =
* Fixed: filtering the Connections tab to "Direct" (an entry source with no referrer, UTM, or ad click-id) — whether via a Cloud node click or the "Show only" dropdown — always returned zero results. The server matched a visitor's raw entry domain against the filter value without the same "no domain → Direct" fallback used to build the "Direct" option itself, so it could never match.

= 2.8.14 =
* The Connections tab's Cloud view nodes are now clickable: clicking a page, entry source, or exit destination filters the graph and journeys down to that node's connections (either direction), replacing any previous selection. A green "Reset" badge appears above the Traffic cloud card while a node filter is active, to clear it.

= 2.8.13 =
* The Connections tab's premium lock now shows a teased preview — a static illustrative traffic-cloud diagram behind a soft scrim — instead of a plain "This is a premium feature" card, so an unlicensed admin can see what the feature actually looks like before unlocking it.

= 2.8.12 =
* The Connections tab is now a premium feature, gated the same way as Top Referrers/UTM Campaigns: an unlicensed site sees a "This is a premium feature — Unlock with a license" card instead of the Cloud/Heat map views, enforced both in what's rendered and in the AJAX endpoint itself.

= 2.8.11 =
* Strengthened the Cloud view's left/middle/right bias so entry sources, pages, and exits consistently land in their own band instead of only loosely leaning that way.

= 2.8.10 =
* Fixed 2.8.9: the Cloud view's muting was wrongly applied to every exit-class node (including real exit-click destinations like an outbound domain) instead of only the generic "Exit" (no known destination) node — a real destination now keeps its full visual weight.

= 2.8.9 =
* The Connections tab's Cloud view now mutes the "Exit" node and the lines leading into it slightly, so real page-to-page traffic reads as the visual priority over a visitor simply leaving.

= 2.8.8 =
* Added a "Connections tab" section to the Documentation tab, explaining the Cloud and Heat map views and how the shared filters/Top N control work.

= 2.8.7 =
* The Connections tab's Cloud view now spreads nodes wider than it does taller — a shorter, wider canvas and a stronger pull back toward the vertical middle, so the graph reads left-to-right instead of growing into a tall, roughly circular cloud.

= 2.8.6 =
* Moved the Connections tab's Cloud/Heat map toggle to its own left-aligned row below the date and filter controls, instead of sharing their row.

= 2.8.5 =
* Fixed the Heat map's row (page) names disappearing entirely — the fixed-width table layout in 2.8.4 only reads column widths from the header row, so the row-name column's own width was silently ignored and collapsed to nothing. Row names now wrap onto multiple lines instead of being cut off, and data cells size themselves from the panel's actual width instead of stretching oversized when there are only a few pages.

= 2.8.4 =
* The Connections tab's Heat map no longer scrolls horizontally on a wide matrix (per feedback that scrolling wasn't wanted) — columns now scale down to fit the panel's width instead, same as the row-name column and cell text.

= 2.8.3 =
* Fixed the Connections tab's Heat map spilling past the edge of its panel on a site with many pages — it now scrolls horizontally within its own container (row names stay pinned in view) instead of overflowing the page.

= 2.8.2 =
* Gave the Connections tab's Cloud view more breathing room: nodes push further apart from each other and from linked nodes instead of settling close together.

= 2.8.1 =
* Fixed the Connections tab's Cloud view and Heat map: page titles with special characters (e.g. curly quotes) were showing as raw HTML entity codes instead of the actual characters. Long page titles now truncate to fit inside a node/column header instead of overflowing across neighbouring ones (hover for the full title). Cloud view links now always curve, and nodes settle into a loose left-to-right entry → page → exit arrangement instead of a fully undirected scatter.

= 2.8.0 =
* New "Connections" tab: see how traffic actually flows between entry sources, pages, and exit points — as a "Cloud" network diagram or a Heat map transition matrix, using the same date-range and "Show only" filters as the Dashboard tab, plus a new "Top N pages" control to keep busy sites readable.
* Fixed: the Dashboard's "Visitor Journeys" table (and any "Show only" filtering built from it) previously treated all of a visitor's page views across the whole selected date range as one continuous journey, even across a multi-day gap with no activity in between. A visitor is now split into a new journey whenever more than 30 minutes pass with no activity — so, for example, a visit on Monday and a separate visit on Thursday within the same report window now correctly show as two rows instead of one merged one.

= 2.7.29 =
* Fixed the "Show only" filter's search box still not matching the "Show" button's height (the previous fix's fixed pixel height didn't account for WordPress's own minimum button height) — the input now stretches to match the button's actual rendered height instead of guessing a fixed value.

= 2.7.28 =
* Fixed the "Show only" filter's search box and "Show" button not lining up at the same height.

= 2.7.27 =
* Moved the "Show only" filter's search box next to the "Show" button (in the space beside it) instead of its own row above it.

= 2.7.26 =
* The "Show only" filter dropdown now has a search box below the list — type 3 or more characters to instantly narrow the list to matching pages/links.

= 2.7.25 =
* Fixed: the "Show only" filter's hit count could show a stale, misleading number next to an item that's selected but has zero matches in the currently-viewed window (a leftover count from whatever window it last had matches in). That count is now hidden for those items.

= 2.7.24 =
* The "Show only" filter dropdown now shows a right-aligned, muted hit count in parentheses next to each page/link suggestion, so you can see how many hits match before selecting it.

= 2.7.23 =
* Settings tab: the Blocked IPs list is now collapsible (collapsed by default, showing just the title and count) and paginated (10 per page) instead of rendering every blocked IP at once. Added a "Remove" button next to the manual add-IP field (for removing a pseudonymised IP that's shown truncated in the table) and a "Remove all" button to clear the whole list in one action.

= 2.7.22 =
* The Hits chart (Week/Month/Custom range views) now shows a flat 0 line for days with no hits instead of skipping them — most visible with a "Show only" filter active, where the chart previously jumped straight between the few days that had matching hits.

= 2.7.21 =
* Fixed: "Show only: [a page]" counted and displayed EVERYTHING a matching visitor did, not just their views of that page — Total Views, the Hits chart, Top Referrers/Campaigns/Shares, and each Visitor Journeys row could all include a matching visitor's completely unrelated activity, sometimes from days the selected page didn't even exist yet. All of these now count/show only the actual hits on the selected page(s). (Filters combining an "entry" or "exit" item alongside pages still show the whole matching visitor's journey, as before — those don't have a single matching hit to narrow to.)

= 2.7.20 =
* Fixed: clicking a Hits chart point while a "Show only" filter was active matched visitors against their activity across the WHOLE period, not the clicked day — so drilling into a day could show visitors' unrelated browsing from that day, justified only by something they did on a different day entirely. "Show only" + a chart-point click now both narrow to the same day.

= 2.7.19 =
* Fixed: the Hits chart's point-filter chip was visible on page load and didn't hide again after clicking "Clear" (a CSS rule was overriding the hidden state). Fixed: the clicked point's highlight/marker line never appeared on the 24-hour (Day) view specifically, because that view's rolling window shifts by a few seconds on every refresh. Fixed: combining a chart-point click with an active "Show only" filter could show the wrong data in the multi-series chart or leak premium fields on an unlicensed site.

= 2.7.18 =
* Dashboard Hits chart: clicking a data point no longer changes the chart itself — it now stays showing the full period, with the clicked point highlighted and a dashed marker line down to the axis. The Visitor Journeys table and summary cards still narrow to that point.

= 2.7.17 =
* Dashboard Hits chart: the 24-hour (Day) view's hourly points are now also clickable, drilling into that single hour the same way Week/Month/Custom range points drill into a day.

= 2.7.16 =
* Dashboard Hits chart: click a data point on the Week/Month/Custom range view to filter the Visitor Journeys table (and the rest of the dashboard) down to just that day. A "Showing [date] — Clear" chip appears above the chart while filtered; click it to return to the full period.

= 2.7.15 =
* Dashboard Visitor Journeys card: the "Approx time on site" column now shows the full hh:mm:ss format (previously m:ss, which read oddly past 60 minutes) and its header reflects this.

= 2.7.14 =
* The Settings tab's Tracking card now shows its real saved content (selectors, ignored referrers) muted behind the license CTA on an unlicensed site, instead of hiding it outright — the fields stay disabled and non-interactive either way.

= 2.7.13 =
* The Settings tab's Tracking card ("Track clicks on" selectors and Ignored referrers) now requires an active license to view or edit — shown as a locked placeholder otherwise. Already-saved selectors/referrer prefixes keep being enforced during tracking even if a license lapses; only the Settings UI to change them locks.

= 2.7.12 =
* The block (🚫) and delete row buttons in Visitor Journeys now require an active license — shown muted and disabled, with a tooltip, on an unlicensed site. GDPR erasure and IP blocking both remain fully available via their Settings tab fields either way.
* Replaced 2.7.11's CSS wrap fix with truncation: a long pseudonymised-IP hash in the Blocked IPs card is now shortened the same way the Visitor Journeys table already shortens one, instead of wrapping.

= 2.7.11 =
* Fixed a long pseudonymised-IP hash overflowing the Blocked IPs/Ignored Referrers table on the Settings tab, pushing the Remove button out past the card's border.

= 2.7.10 =
* Added a block button (🚫) to each Visitor Journeys row that adds that visitor's IP address to a block list, managed from a new "Blocked IPs" card on the Settings tab. Blocked IPs are dropped from tracking going forward.
* Fixed the per-row delete button (2.7.9) being rejected on sites with IP pseudonymisation enabled, since the visitor identifier is a hash rather than a raw IP address in that case.

= 2.7.9 =
* Added a delete button to each Visitor Journeys row to erase that visitor's tracked data.

= 2.7.8 =
* The "Show only" filter and pagination beyond 20 rows per table now require an active license.

= 2.7.7 =
* Swapped the dashboard's card positions: Top Referrers now sits next to UTM Campaigns, and Shares now sits next to Top Pages.

= 2.7.6 =
* Right-aligned the "Show only" filter (and its Match any/Match all toggle) in the period-selector row, so the date-range controls and the filter controls sit at opposite ends instead of running together mid-row.

= 2.7.5 =
* Fixed the "Show only" control disappearing entirely (instead of showing a muted selection) when paging to a window with zero visitor data at all.

= 2.7.4 =
* Fixed the multi-series chart's pill legend showing a raw filter key (e.g. "np-vj-link-in:example.com") instead of a real label — lastAllVisitorJourneys was being updated after the chart render instead of before it, so the pill labels were built from the previous window's (or, on first load, empty) data. Pills for an item with genuinely zero matches in the current window now also fall back to its last-known label instead of the raw key.

= 2.7.3 =
* Fixed the "Show only" button misleadingly falling back to "All pages & links" after paging (Previous/Next) to a window where the selected page/link has no matches — it now keeps showing the real selection, styled muted, instead of looking like the filter was cleared. The same item now also stays visible (muted, still deselectable) in the filter panel's option list instead of disappearing from it.

= 2.7.2 =
* The "Show only" filter selection is now preserved when using the Previous/Next window buttons — only changing the date range dropdown or applying a custom range resets it, since Prev/Next just slides the same window rather than switching to a different kind of view.

= 2.7.1 =
* Fixed the "Show only" filter panel's option list jumping back to the top every time an option was clicked — scroll position is now preserved across the re-render.

= 2.7.0 =
* Dashboard date range is now rolling windows instead of calendar periods: "24 hrs", "7 days", "30 days", "12 months" (Custom range unchanged). Previous/Next now shifts the whole rolling window back or forward by one window-length.
* The "Show only" filter moved next to the date range selector, gained a Match any/Match all toggle for combining multiple selected pages/links, and now applies to the entire dashboard — chart, summary cards, and top-lists all recompute against the filtered visitor set, not just the Visitor Journeys table.
* The chart becomes multi-series (one line per selected item, with a colored pill legend below it) once 2+ "Show only" items are selected.

= 2.6.2 =
* The campaign badge (Source column and expanded journey view) now also shows utm_content, stacked as a smaller line under the campaign/source value, and utm_content appears alongside Campaign/Term/ID in the UTM Campaigns detail row.
* Fixed the Source column favicon not resolving for a bare UTM source word containing a hyphen (e.g. utm_source=ikamo-productions, from a visit with no referrer) — confirmed live that the real domain often omits hyphens a campaign slug includes for readability (ikamo-productions.com doesn't exist; ikamoproductions.com does, and has a real icon). Now also tries the hyphen-stripped domain before giving up.

= 2.6.1 =
* Fixed Visitor Journeys silently capping at 100 visitors (10 pages of 10) regardless of how much real traffic a period had — get_visitor_journeys() now returns every visitor in the selected period; the existing client-side pagination and "Show only" filter (which already worked correctly) now run over the complete, accurate dataset instead of a pre-truncated one.

= 2.6.0 =
* Added a "Clear icon cache" button (Settings > Maintenance) to empty the 30-day Source column favicon cache on demand — useful after a referring site fixes its own icon, or after a plugin update changes how icons are resolved.
* Reorganised the Settings tab into cards: Appearance, Privacy, Data Retention & Backup, and Tracking save together via one "Save Settings" button; Maintenance and Data Management below it are independent actions, applied immediately.

= 2.5.2 =
* The Source column favicon now resolves the way a browser does: it reads `<link rel="icon">` from the domain's own homepage HTML first, falling back to the legacy /favicon.ico convention only if that isn't found. Most modern sites — including WordPress's own "Site Icon" feature — declare their icon this way rather than at /favicon.ico, so domains that previously fell back to the globe despite having a real, working icon (confirmed live: this very site, and referring site kajo-art.com) should now resolve correctly.
* Added a bundled fallback for LinkedIn specifically: confirmed live that linkedin.com returns HTTP 200 but an HTML page (not the icon) when requested from this site's own hosting IP — an IP-reputation block on the requesting server, unrelated to the request itself, that no live-fetch strategy can work around. A copy of the icon ships with the plugin and is used once live attempts are exhausted.

= 2.5.1 =
* Diagnostic only: ajax_get_favicon() now logs the actual reason a favicon fetch failed (via error_log(), same as other failures in this file), and exposes it as an X-NP-Favicon-Debug response header when the request includes ?debug=1 (capability-gated, same as the rest of the endpoint) — needed to track down why LinkedIn's favicon specifically wasn't loading despite working fine from a plain command-line fetch.

= 2.5.0 =
* Replaced the last several patches to Visitor Journeys' Source column favicon (a root-domain retry, a bundled icon for Instagram, UTM-source domain guessing) with a single server-side fetch: WordPress now fetches and caches the icon itself (`ajax_get_favicon()`) instead of the browser fetching it directly. This is more reliable, not just simpler — a live browser fetch is blocked outright by some platforms (Instagram) regardless of any client-side workaround, since `wp_remote_get()` carries none of the fetch-metadata headers a browser does. The same server-side method also tries a bare UTM source value as a domain guess (e.g. `linkedin` → `linkedin.com`) when a visit has no referrer at all, and retries against the root domain (e.g. `instagram.com` from `l.instagram.com`) if the exact domain's favicon isn't found — covering LinkedIn (no Referer sent on outbound clicks) and link-redirect subdomains in the same one place. A domain with no favicon at all (confirmed: some referring sites just don't have one) still falls back to the 🌐 globe, correctly — there's nothing to fetch.

= 2.4.9 =
* Instagram's favicon still wasn't loading after 2.4.8's fix: confirmed live that Instagram blocks the fetch based on the Sec-Fetch-Site: cross-site header every browser attaches automatically to cross-origin requests (unlike Referer, there's no attribute that suppresses this one), regardless of domain or referrer policy — Facebook's favicon endpoints don't do this, which is why that platform worked. No client-side fix exists for it, so Instagram's icon now ships bundled with the plugin (assets/known-icons/instagram.png) and is used directly instead of fetched live.

= 2.4.8 =
* Fixed the Source column favicon (and its root-domain fallback) not loading for referrer-sensitive sites like Instagram, confirmed live: Instagram serves the real favicon.ico only when no Referer header is sent, but silently serves an HTML page (200 OK, wrong content type) instead when one is present — which a plain `<img>` sends by default (this site's own admin URL), so the fetch "failed" regardless of the domain being correct. The favicon now loads with `referrerpolicy="no-referrer"`, which also means this site's admin URL is no longer leaked to whichever domain a favicon is fetched from.

= 2.4.7 =
* Visitor Journeys' Source column (favicon/globe icon and campaign badge) is now a premium feature, matching Top Referrers and UTM Campaigns. Unlicensed sites see a lock icon in its place — in both the table row and the expanded journey view — instead of the real referrer/UTM data; the server no longer sends that data to unlicensed sites at all (nothing to see via the browser inspector), same protection as the other premium features. Page titles, URLs, and timestamps are unaffected and remain free.

= 2.4.6 =
* The Source column's favicon now retries against the referrer's root domain (e.g. instagram.com) if the exact subdomain's favicon fails to load — fixes link-redirect subdomains like Instagram's l.instagram.com, which redirect /favicon.ico to their homepage instead of serving an icon there, falling through to the 🌐 globe unnecessarily. Best-effort (last two labels, not a full public-suffix lookup), so it's wrong for compound-TLD domains like example.co.uk — but a wrong guess just costs one extra failed request before falling back to the globe as before, no worse than not trying.

= 2.4.5 =
* When Settings > Appearance > "Show campaign badge value as text" is on, the Source column's campaign badge is now capped at a fixed width and truncated with an ellipsis instead of growing freely with the value's length — 2.4.4's fixed column width only covered the icon-only case, so a long campaign name in text mode could still pad out every other row's Source column the same way. The full value is still available via the badge's own tooltip.

= 2.4.4 =
* Gave the Source column an explicit fixed width (sized for an icon, or icon plus bare campaign glyph) instead of relying on the table's auto-sizing, which still let a single campaign row on the page pad out every other row's Source column to match it. The column can still grow wider than this when needed (e.g. Settings > Appearance > "Show campaign badge value as text" is on) — this is a floor, not a hard cap.

= 2.4.3 =
* Fixed the Source column padding out every plain-globe row when only one row on the page had a campaign badge — an HTML table column auto-sizes to its widest cell, and the badge's pill styling (background/border/padding) made that one row wide enough to stretch the whole column. The default icon-only badge is now a bare 🏷 glyph, the same visual weight as the globe/favicon next to it, so it no longer inflates the column for rows that don't have one. The full pill styling still applies when Settings > Appearance > "Show campaign badge value as text" is on, and in the expanded journey view (which always shows the value as text).

= 2.4.2 =
* In the expanded journey view, the referrer pill and its campaign badge are now center-aligned within their stack (rather than left-aligned), and the arrow between that stack and the next page pill is vertically centered against the whole stack instead of sitting at the top, glued to the referrer pill's own line.

= 2.4.1 =
* Reduced the campaign badge's padding — it was noticeably taller/wider than the plain favicon/globe icon next to it in the icon-only Source column, making that row look bulkier than others.

= 2.4.0 =
* The Source column's campaign badge is now icon-only (🏷, no visible UTM value) by default, matching the source favicon/globe icon next to it — hover for the value. The expanded journey view's own badge is unchanged and still shows the value as text.
* Added a new Appearance card at the top of the Settings tab with a "Show campaign badge value as text" toggle, for anyone who'd rather always see the UTM value in the table without hovering.
* In the expanded journey view, the campaign badge now sits below the entry (referrer) pill instead of beside it, so it reads as detail about the referrer rather than another step in the visitor's path alongside the page pills.

= 2.3.7 =
* The Source column is now the very first column (before the expand arrow), and its header text is removed — the icon speaks for itself, with detail on hover.
* When a visit has separate UTM campaign data alongside a real referrer, the same small campaign badge already shown in the expanded journey now also appears next to the Source column's icon, so it's visible without expanding the row.

= 2.3.6 =
* The Visitor Journeys table's Source column now leads the table (first column after the expand arrow) and shows an icon instead of text: a favicon fetched directly from the referring site itself (no third-party favicon service involved) when a real referrer is known, or a 🌐 globe for a direct visit, a UTM-only source, or a referrer whose favicon couldn't be loaded. Hovering the icon shows the full detail (domain/URL, or the UTM value) as a tooltip.

= 2.3.5 =
* Visitor Journeys now shows how a visitor arrived without expanding a row: a new "Source" column on the main table shows the referring domain, UTM source, or "Direct". Previously this was only visible inside each row's expanded journey pills.
* When a visit has both a real referrer (e.g. a click from facebook.com) and separate UTM campaign data, both are now shown instead of one hiding the other — a small campaign badge appears next to the entry pill whenever UTM data adds information beyond what the pill's own label already shows.
* The Visitor Journeys query now also fetches `utm_medium` and `utm_campaign` per page (previously only `utm_source` was available to this view, even though all UTM fields were already being collected).

= 2.3.4 =
* Fixed a data-loss bug: page view tracking relied solely on WordPress's `wp` hook, which only fires when PHP actually executes for a request. A full-page caching plugin (LiteSpeed Cache, WP Rocket, etc.) serving a page straight from cache bypasses PHP entirely, so that page view — and whatever referrer brought the visitor there — was silently never recorded. This was most visible on landing pages, where it could make a Visitor Journey start mid-session with no indication of how the visitor arrived. Page views are now also reported by a client-side beacon (the same mechanism outbound click tracking already used, which was unaffected by this bug), which fires from the visitor's real browser regardless of caching. The existing `wp`-hook recorder is unchanged and still runs alongside it; a duplicate recording of the same pageload (matched on IP + URL within 2 seconds) is skipped so page view counts aren't inflated.

= 2.3.3 =
* Fixed a real data-loss bug: importing a JSON backup exported from a completely different site replaced this site's data with no domain-mismatch warning at all. Root cause: a file with no (or an unparseable) site_url was treated as "nothing to compare, assume it's fine" rather than requiring confirmation — so any file missing that field bypassed the safety check entirely, regardless of its actual origin. The check now fails closed: only a file whose site_url is a POSITIVELY VERIFIED match for this site skips confirmation; anything else (a different domain, or no domain info at all) requires the "Import anyway" step.
* CSV (.zip) exports/imports never carried a site_url at all, so they had no domain-mismatch protection whatsoever, even before the above bug. CSV exports now include a small manifest.json with the site's URL, giving CSV imports the same protection JSON imports have always had.
* Reordered the Transfer data tab: Export, then Import, then (when applicable) the mismatch/unverified-origin confirmation card, so it appears directly below the action that triggered it. The two destructive confirm buttons ("Import and replace data", "Import anyway") are now styled red.

= 2.3.2 =
* Replaced 2.3.1's no-signal "in progress" bar with a real one: import now uploads in two steps — the file uploads via AJAX to a new fast validate-and-stash step (accurate upload percentage shown, since this request never touches the database and returns quickly), then the actual replace of hits/clicks (and settings, for a JSON file) is triggered as a plain page navigation, avoiding the exact host-timeout failure mode that broke 2.3.0's large imports. Domain-mismatch confirmation is unaffected — it's the same confirmation step as before, just reachable from either import path.

= 2.3.1 =
* Fixed a regression from 2.3.0: the Import form's XHR-based progress bar caused large imports (confirmed with a 12.7MB file, 35,000+ rows) to fail with a network error partway through server-side processing, on a real production install (norttipaikalle.fi), even though the same import worked fine on 2.2.x. Likely cause: a long-running XHR/AJAX request is subject to a shorter idle-timeout on some hosts' reverse proxy/CDN than a full-page navigation gets. The import form now submits as a normal page navigation again (the exact mechanism 2.2.x used), with the progress bar showing an honest "in progress, don't navigate away" indicator instead of a byte-accurate upload percentage.

= 2.3.0 =
* Added a CSV export/import option to Transfer data, alongside the existing full JSON backup. CSV export downloads hits.csv + clicks.csv zipped together — data only, no settings; useful for opening in a spreadsheet, or a data-only restore that leaves this site's own settings untouched. Import now accepts either a .json or .zip file and detects which by extension.
* Added a progress bar to the Import card: an "Uploading…" phase driven by real upload progress (useful for large multi-MB backups), then an animated "Processing…" phase while the server validates and writes the data, before automatically landing on the result notice.
* Fixed fputcsv()/fgetcsv() deprecation notices on PHP 8.4+ by passing the escape character explicitly.

= 2.2.5 =
* Fixed the 2.2.4 fix being incomplete: get_option()'s default argument only covers a setting that was never saved, not one whose row exists but literally holds `false` (found in the wild on norttipaikalle.fi, likely a carry-over from the old wpstats_* option migration). Export and import now both treat a stored `false` the same as "unset" for every affected setting (retention_months, delete_on_uninstall, ignored_referrers, tracked_selectors), so this class of bad-schema false-rejection can't recur regardless of what's actually sitting in wp_options.

= 2.2.4 =
* Fixed Transfer data export/import wrongly rejecting valid files with "File does not match the expected NP Visitor Journeys export format." Three settings (Ignored Referrers, CSV export, Track clicks on) are only saved to the database the first time their form is submitted; on a site where one had never been touched, get_option() returned false instead of a string, which got exported literally as `false` in the JSON and then failed import's type check. Export now supplies the correct default for every setting, and import now also tolerates `false` in these fields from files exported before this fix.

= 2.2.3 =
* Full-project sweep: Transfer data now includes the "Track clicks on" setting (previously silently dropped on export/import); guarded ajax_track_click() against a non-string url parameter that could fatal on PHP 8; removed the dead "Export all as .csv" handler superseded by Transfer data; added a length cap to the "Track clicks on" field.

= 2.2.2 =
* Reordered tabs to Dashboard, Documentation, Settings, Transfer data, License — Documentation now comes right after Dashboard (it explains every other tab) and License moved to the end, matching NP 301's convention of putting account/billing last.

= 2.2.1 =
* Documentation tab examples changed from this site's own real selectors (.kfc-buy, #livetods-ribbon) to generic placeholders (.buy-button, #signup-button), since this plugin is used across sites with different markup.

= 2.2.0 =
* Added a Documentation tab covering every tab's basic usage, plus a step-by-step guide to finding and adding a CSS selector under Settings > "Track clicks on" — including how to recognize when a button lives inside a third-party widget's Shadow DOM (in which case its own class/id can never be matched, and the widget's outer container must be used instead).

= 2.1.4 =
* Fixed tracked clicks disappearing from Visitor Journeys after any further page view was recorded for the same visitor. A click only ever counted as an "exit click" if it was the visitor's chronologically last action in the period — any later browsing (by them, or coincidentally the same IP) pushed it out of view, even though the click still happened. A visitor's most recent click in the period now always shows, regardless of what they did afterward.

= 2.1.3 =
* Fixed clicks silently not appearing in Visitor Journeys on sites using full-page caching (LiteSpeed Cache, WP Rocket, etc.): a cached page load skips WordPress/PHP entirely, so the page-view "hit" for that visit never gets recorded, even though the click itself (an always-executes AJAX call) still does. Visitor Journeys previously required a matching hit to exist before it would show a visitor's click at all, silently dropping click-only visitors. A click now always gets its own journey entry, with or without a page-view hit alongside it.

= 2.1.2 =
* Replaced the "Tracked Events" tab (2.1.0/2.1.1) with a much simpler mechanism: a "Track clicks on" field on the Settings tab, listing CSS selectors (one per line) to record as a click even when there's no real cross-domain link. Matching clicks now show up automatically as the same exit-click pill already used in Visitor Journeys — labeled with the matched selector, the same way a shared link shows its domain — with no separate tab, no click log to browse, and no new database columns.
* Fixes a real regression from 2.1.0/2.1.1: the clicks table gained two new columns (source_url, tracked_event_id) that ordinary outbound-click tracking was made to depend on. On a site where that schema change didn't fully apply, every click — including plain outbound links that were working before — silently failed to record. This release removes that dependency entirely; outbound-click tracking is back to exactly its pre-2.1.0 behaviour.

= 2.1.1 =
* Tracked Events "Add" form now defaults to CSS selector (was Domain / URL pattern) — logging a click regardless of destination is the more common need, and the field's placeholder/help text now updates to match whichever type is selected instead of showing both examples at once.

= 2.1.0 =
* Added a "Tracked Events" tab: define a domain/URL pattern or a CSS selector to follow, and see the full visitor journey (pages visited, in the 24 hours before) leading up to each matching click — useful for JS-driven buttons (e.g. an embedded ticket-shop widget) that never produce a real cross-domain navigation. Domain/URL events match retroactively against clicks already recorded; CSS selector events are matched live in the visitor's browser going forward.
* The clicks table now also stores which page a click happened on (source_url), used by Tracked Events' journey view.

= 2.0.0 =
* Renamed throughout to NP Visitor Journeys: plugin folder/slug (np-stats → np-visitor-journeys), main file, all classes/functions/hooks/options, the two database tables, and CSS/JS classes. Existing installs are migrated automatically on update — the database tables are renamed in place (no data loss) and saved settings are copied across; no action needed.
* Added a License tab (LicMan integration). Top Referrers and UTM Campaigns are now premium features — unlicensed sites see a locked upsell card instead of the live data; everything else (Dashboard totals, Top Pages, Visitor Journeys, Settings, Transfer data) remains free.
* Breaking: the plugin's WordPress folder name changed from np-stats to np-visitor-journeys — on the next update, delete the old np-stats plugin folder after installing this version (WordPress treats it as a new plugin, not an in-place update, since the folder name changed).

= 1.5.3 =
* Transfer data import now checks the uploaded file's recorded site_url against this site's domain (not the filename). If they differ, it warns and requires an explicit "Import anyway" confirmation before replacing any data.

= 1.5.2 =
* Transfer data export filename now includes the site's domain (e.g. np-visitor-journeys-transfer-example.com-2026-08-10.json), so exports from different sites are easy to tell apart.

= 1.5.1 =
* Fixed the Settings and Transfer data tabs' cards sitting flush against the tab row with no top spacing.

= 1.5.0 =
* Added a "Transfer data" tab: export all tracked hits, clicks, and settings to a single JSON file, and import a previously exported file to restore or move that data to another site. Import replaces existing data, and requires an explicit confirmation before running.

= 1.4.4 =
* WordPress.org Plugin Check follow-up: wrapped uninstall.php's logic in a function so its local variables no longer register as unprefixed globals, and corrected three phpcs:ignore comments whose line placement didn't actually cover the flagged code (the underlying code was already safe — table names built from wpdb->prefix, never user input — this only fixes the suppression, not the logic).

= 1.4.3 =
* Documented (via a justified phpcs:ignore) the shared `nortti_paikalle_parent_page()` function's naming-convention warning — it's deliberately identical across every Nörtti paikalle plugin so they share one top-level admin menu; a per-plugin prefix would break that.

= 1.4.2 =
* Removed a filter on the update-transient that suppressed a stale entry for this plugin's old folder name — WordPress.org Plugin Check flags any code that touches this transient as a "plugin updater" (not permitted for hosted plugins), and the folder-collision issue it worked around no longer applies once distributed through the directory under an approved slug.

= 1.4.1 =
* WordPress.org Plugin Check pass: corrected text domain to match the plugin slug (np-visitor-journeys) and removed the unused Domain Path header, escaped remaining unescaped output, switched date() to gmdate() for timezone safety, added wp_unslash() before sanitizing request data, and annotated database queries that build table names internally (never from user input).
* Bumped "Tested up to" to 7.0.

= 1.4.0 =
* WordPress.org & GDPR compliance milestone: added readme.txt and declared a GPLv2-or-later license.
* Bundled Chart.js locally instead of loading it from a third-party CDN, so wp-admin no longer makes an external request on every dashboard load.
* Added automatic data retention: tracked hits/clicks older than a configurable period (default 14 months) are now pruned weekly. Configurable under Settings > Data Retention; set to 0 to keep data forever.
* Added suggested privacy-policy text on Settings > Privacy describing this plugin's data collection.
* Added an "Erase Visitor by IP Address" tool on the Settings tab to fulfil individual data-erasure requests (matches both raw and pseudonymised/hashed storage).
* Corrected "Anonymise IP Addresses" wording to "Pseudonymise IP Addresses" throughout the settings UI and documentation — the hashing this option applies is pseudonymisation, not full anonymisation, and GDPR treats pseudonymised data as still personal data.

= 1.3.3 =
* Renamed display name to "NP Visitor Journeys".

= 1.3.2 =
* Added pagination to Top Pages, Top Referrers, and UTM Campaigns.

= 1.3.1 =
* Maintenance release.

== Upgrade Notice ==

= 1.4.0 =
WordPress.org & GDPR compliance milestone: license declaration, locally-bundled Chart.js, automatic 14-month data retention, privacy-policy content suggestion, per-IP data erasure, and corrected pseudonymisation wording. No breaking changes.
