CPD Activity Funnel
CPD Activity 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):
| # | Stage | Counts |
|---|---|---|
| 1 | Landing view | Any render of the home shortcode (home_view) or the legacy ep-courses landing (landing_view). Unique = distinct IP or session. |
| 2 | Interview started | Distinct interview session_id values with an interview_start event. |
| 3 | Interview completed | Distinct sessions that produced a profile JSON block (interview_complete). |
| 4 | Pathway viewed | Distinct sessions that loaded /my-pathway/ (pathway_view). |
| 5 | First video play | Distinct sessions with one or more pathway_video_play events. |
| 6 | Multi-video session | Distinct sessions with three or more pathway_video_play events. |
Plus two side panels:
- Specialty distribution — frequency of each value in
primary_specialtiesacross all completed interview profiles in range. Pulled frompm_ep_cpd_interviews.profile, not from events. - Top watched videos — counts of
pathway_video_playgrouped bymeta(which holds the Vimeo ID), joined againstinventory-taxonomy.jsonfor 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 twopm_ep_eventscolumns, hooksinterview_start,interview_complete,pathway_view, handles thecpd_trackpublic endpoint and theget_funneladmin endpoint, registers the settings panel.ep-cpd-interview/js/admin-funnel.js— admin panel JS. Loads on the settings page, fires theget_funnelAJAX 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— firespathway_video_playtocpd_trackwhenever the Vimeo modal opens on/my-pathway/. Carries the session id via thedata-sessionattribute on the modal div.ep-cpd-home/plugin.php— fireshome_viewon every render of the[cpd-home]shortcode. Has its own inlinelog_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.
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_type | Fired by | session_id | meta |
|---|---|---|---|
home_view | ep-cpd-home::render_cpd_home() at the top of the shortcode | NULL | NULL |
interview_start | ep-cpd-interview::handle_start() after the INSERT into interviews table | session | NULL |
interview_complete | ep-cpd-interview::handle_message() when a <profile> block is extracted | session | NULL |
pathway_view | ep-cpd-interview::render_pathway() when a completed interview loads its pathway page | session | NULL |
pathway_video_play | pathway.js::openFor() via POST to method=cpd_track, fired the moment the Vimeo modal opens | session | vimeo_id |
landing_view | ep-courses legacy course landings (unchanged). Counted as part of stage 1. | NULL | NULL |
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:
- 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-dashboardcontainer. admin-funnel.jsis registered viajs_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.- ep-cpd-interview's
fetch()method catches that POST, checks the current user has admin access, verifies the CSRF token againstcsrf_token(), then callshandle_get_funnel($data). handle_get_funnelruns four queries and echoes JSON: the funnel stages, the specialty distribution, the top videos, and the total video play count.- JS renders the response into HTML bars, pills, and a table.
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
- 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.
- Call
$this->log_event('your_event_name', $session_id, $meta)at the trigger point. Session id and meta are both optional. - If the trigger is frontend-only (JS), extend the allow-list in
handle_cpd_track()inside ep-cpd-interview, then POST from JS withmethod=cpd_track&type=your_event_name&session=...&meta=.... - 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
- In
handle_get_funnel(), add the SQL for your stage. If it counts distinct sessions, follow the patternSELECT 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. - Append an entry to the
$funnelarray withkey,label,count, and optionallyunique. - That's it — the JS renders whatever stages it receives, so you don't touch
admin-funnel.jsunless the stage needs bespoke styling.
Recipe: add a new side metric
- Query your data inside
handle_get_funnel(). Shape it as a JSON-friendly array. - Add the result to the JSON echo under a new key (e.g.
'dropoff_reasons'). - In
admin-funnel.js’srender(), add a new.cpd-funnel-sectionblock that readsres.your_new_keyand emits HTML. Reuse the existing.cpd-funnel-bar/.cpd-funnel-tableclasses 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
| Purpose | Method | Who | CSRF | Payload |
|---|---|---|---|---|
| Log a frontend event | POST / | Public | No | pm_ajax=EP_CPD_Interview&method=cpd_track&type=pathway_video_play&session=<hash>&meta=<vimeo_id> |
| Load funnel data | POST /admin/plugins | Admin | Yes | pm_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.