CPD Activity Funnel

← All sections

CPD Activity Funnel

Built 2026-04-23 Admin: /admin/plugins → EP CPD Interview → CPD Funnel

This page is the reference for the CPD Activity funnel panel: what it measures, how it's wired, how to extend it, and how to roll it back. The panel answers one question: of everyone who lands on the site, how many go all the way through the interview to actually watching UCT lectures?

What it measures

Six-stage funnel, counted over a user-selected date range (Today / Last 7 days / Last 30 days / All time):

#StageCounts
1Landing viewAny render of the home shortcode (home_view) or the legacy ep-courses landing (landing_view). Unique = distinct IP or session.
2Interview startedDistinct interview session_id values with an interview_start event.
3Interview completedDistinct sessions that produced a profile JSON block (interview_complete).
4Pathway viewedDistinct sessions that loaded /my-pathway/ (pathway_view).
5First video playDistinct sessions with one or more pathway_video_play events.
6Multi-video sessionDistinct sessions with three or more pathway_video_play events.

Plus two side panels:

  • Specialty distribution — frequency of each value in primary_specialties across all completed interview profiles in range. Pulled from pm_ep_cpd_interviews.profile, not from events.
  • Top watched videos — counts of pathway_video_play grouped by meta (which holds the Vimeo ID), joined against inventory-taxonomy.json for the title and specialty label.

Files involved

CPD site/var/www/cpd/user-content/plugins/

  • ep-cpd-interview/plugin.php — owns the funnel. Adds the two pm_ep_events columns, hooks interview_start, interview_complete, pathway_view, handles the cpd_track public endpoint and the get_funnel admin endpoint, registers the settings panel.
  • ep-cpd-interview/js/admin-funnel.js — admin panel JS. Loads on the settings page, fires the get_funnel AJAX on tab open and on Refresh / range change, renders funnel bars + specialty bars + top-videos table.
  • ep-cpd-interview/css/admin-funnel.css — brand-matched styling (Fraunces heads, Inter body, deep teal bars, coral accent).
  • ep-cpd-interview/js/pathway.js — fires pathway_video_play to cpd_track whenever the Vimeo modal opens on /my-pathway/. Carries the session id via the data-session attribute on the modal div.
  • ep-cpd-home/plugin.php — fires home_view on every render of the [cpd-home] shortcode. Has its own inline log_event() helper.

Backups from the 2026-04-23 deploy sit next to each modified file with suffix .bak.20260423193112.

Schema additions

The shared pm_ep_events table (owned by ep-courses) gained two columns, added by the ep-cpd-interview construct() method using a defensive column-check ALTER. Additive only; does not break ep-courses reads.

session_id  VARCHAR(64)  NULL  — interview session_id for grouping
meta        VARCHAR(191) NULL  — free-form label (currently the Vimeo ID for plays)

Existing columns continue to mean what they always meant: event_type, course_slug, lesson_sort, user_id, ip, user_agent, referrer, created_at. CPD events leave course_slug and lesson_sort NULL.

Why not overload course_slug? We could have crammed session IDs into course_slug and Vimeo IDs into lesson_sort, but the ep-courses Activity tab reads those columns with their original course semantics, so values would misrender there. Separate columns keep both plugins clean.

Event types, where they fire

event_typeFired bysession_idmeta
home_viewep-cpd-home::render_cpd_home() at the top of the shortcodeNULLNULL
interview_startep-cpd-interview::handle_start() after the INSERT into interviews tablesessionNULL
interview_completeep-cpd-interview::handle_message() when a <profile> block is extractedsessionNULL
pathway_viewep-cpd-interview::render_pathway() when a completed interview loads its pathway pagesessionNULL
pathway_video_playpathway.js::openFor() via POST to method=cpd_track, fired the moment the Vimeo modal openssessionvimeo_id
landing_viewep-courses legacy course landings (unchanged). Counted as part of stage 1.NULLNULL

The funnel panel is additive: the more of the CPD flow we wire into log_event(), the more it sees. Every event write is wrapped in try/catch and silently swallows errors, so no logging failure can ever block a user flow.

How the admin panel is wired

PageMotor discovers admin panels through each plugin's settings() method. ep-cpd-interview's settings() returns one group (group-funnel) with a single custom-HTML field that renders the dashboard container. The actual data loads asynchronously:

  1. PageMotor calls settings() during render of /admin/plugins. The returned HTML includes #cpd-funnel-admin (carrying the CSRF token as a data attribute) and the empty #cpd-funnel-dashboard container.
  2. admin-funnel.js is registered via js_settings() and loads on that page. On DOMReady it POSTs {pm_ajax: "EP_CPD_Interview", data[action]: "get_funnel", data[range]: "30d", data[ep_csrf_token]: TOKEN} to the same URL.
  3. ep-cpd-interview's fetch() method catches that POST, checks the current user has admin access, verifies the CSRF token against csrf_token(), then calls handle_get_funnel($data).
  4. handle_get_funnel runs four queries and echoes JSON: the funnel stages, the specialty distribution, the top videos, and the total video play count.
  5. JS renders the response into HTML bars, pills, and a table.
CSRF + admin gate. The get_funnel endpoint is admin-only and mirrors the exact pattern ep-courses uses for its own admin AJAX. The public cpd_track endpoint skips CSRF (cross-site tracking would be pointless to block) but only accepts the pathway_video_play event type on an explicit allow-list, so nobody can forge arbitrary event_type values from the browser.

How to verify events are flowing

SSH into the VPS and run these checks. All commands are safe read-only SELECTs.

ssh s2l-vps
cd /var/www/cpd
DBU=$(php -r 'include "config.php"; echo DB_USER;')
DBP=$(php -r 'include "config.php"; echo DB_PASSWORD;')
DBN=$(php -r 'include "config.php"; echo DB_NAME;')

# Counts per event type over the last 7 days
mysql --default-character-set=utf8mb4 -u"$DBU" -p"$DBP" "$DBN" -e "
SELECT event_type, COUNT(*) n, COUNT(DISTINCT session_id) distinct_sessions
FROM pm_ep_events
WHERE created_at > NOW() - INTERVAL 7 DAY
GROUP BY event_type ORDER BY n DESC;"

# Funnel completion: a specific session's full journey
mysql --default-character-set=utf8mb4 -u"$DBU" -p"$DBP" "$DBN" -e "
SELECT event_type, meta, created_at
FROM pm_ep_events
WHERE session_id = 'SESSION_HASH_HERE'
ORDER BY created_at;"

To verify end-to-end without waiting for real traffic, load the home page, start an interview via /interview/, and open the pathway page for any completed session. Each step should add a row to pm_ep_events.

Recipe: add a new event type

  1. Decide which plugin the trigger belongs to. If it's a page render, put it in the plugin that owns the shortcode. If it's an admin action, put it in the admin handler.
  2. Call $this->log_event('your_event_name', $session_id, $meta) at the trigger point. Session id and meta are both optional.
  3. If the trigger is frontend-only (JS), extend the allow-list in handle_cpd_track() inside ep-cpd-interview, then POST from JS with method=cpd_track&type=your_event_name&session=...&meta=....
  4. If the plugin doesn't already have a log_event() helper, copy the ten-line version from ep-cpd-home. Do not reach across to ep-courses — that plugin stays in lock-step with Kenn's s2l.online twin and must not grow new public surface.

Recipe: add a new funnel stage

  1. In handle_get_funnel(), add the SQL for your stage. If it counts distinct sessions, follow the pattern SELECT COUNT(DISTINCT session_id) FROM pm_ep_events WHERE $where_time AND event_type='your_event'. If it counts with a threshold (like multi-video), do the GROUP BY in PHP.
  2. Append an entry to the $funnel array with key, label, count, and optionally unique.
  3. That's it — the JS renders whatever stages it receives, so you don't touch admin-funnel.js unless the stage needs bespoke styling.

Recipe: add a new side metric

  1. Query your data inside handle_get_funnel(). Shape it as a JSON-friendly array.
  2. Add the result to the JSON echo under a new key (e.g. 'dropoff_reasons').
  3. In admin-funnel.js’s render(), add a new .cpd-funnel-section block that reads res.your_new_key and emits HTML. Reuse the existing .cpd-funnel-bar / .cpd-funnel-table classes for visual consistency.

Rollback

Everything changed on 2026-04-23 was backed up with timestamp 20260423193112 (plugin and JS files) and 20260423193447 (docs pages). To revert:

# CPD plugin files — restore the three modified originals
ssh s2l-vps
TS=20260423193112
cd /var/www/cpd/user-content/plugins
cp ep-cpd-interview/plugin.php.bak.$TS ep-cpd-interview/plugin.php
cp ep-cpd-home/plugin.php.bak.$TS      ep-cpd-home/plugin.php
cp ep-cpd-interview/js/pathway.js.bak.$TS ep-cpd-interview/js/pathway.js

# Remove the two new files
rm ep-cpd-interview/js/admin-funnel.js
rm ep-cpd-interview/css/admin-funnel.css
rmdir ep-cpd-interview/css  # only if empty

# Docs pages — restore decisions-log and overview
# Backups are on the VPS at /root/docs-backups/*.20260423193447.html
# Use the update-docs.php pattern to write them back, or restore via admin.

The two new pm_ep_events columns (session_id, meta) can be left in place safely — they're additive and don't affect ep-courses reads. Drop them only if you're certain no other code expects them:

ALTER TABLE pm_ep_events DROP COLUMN session_id, DROP COLUMN meta;

Endpoints reference

PurposeMethodWhoCSRFPayload
Log a frontend eventPOST /PublicNopm_ajax=EP_CPD_Interview&method=cpd_track&type=pathway_video_play&session=<hash>&meta=<vimeo_id>
Load funnel dataPOST /admin/pluginsAdminYespm_ajax=EP_CPD_Interview&data[action]=get_funnel&data[range]=today|7d|30d|all&data[ep_csrf_token]=<token>

The allow-list for frontend events sits in handle_cpd_track(). Extend it there to admit new types from JS.

Decisions referenced