Author: Qi Xiang Phang
Date: January 2025
Document Type: Research Deliverable (Feature 6.1)
This document investigates implementable methods for collecting anonymous usage data within the Guide on the Side tutorial system. The goal is to enable librarians and administrators to measure tutorial effectiveness—understanding how students interact with content, where they struggle, and which tutorials perform well—without compromising user privacy.
Analytics serve a practical purpose: librarians need to know if their tutorials are working. Are students completing them? Which slides cause confusion (high dwell time, low quiz scores)? Which tutorials get the most traffic? This data informs content improvements and demonstrates value to stakeholders.
The client (Melissa Belvadi) established clear privacy boundaries during initial requirements gathering:
| Constraint | Implication |
|---|---|
| No PII collection | Cannot store names, emails, IP addresses, or any data that identifies individuals |
| No student login required | Cannot rely on user accounts for tracking; all users are effectively anonymous |
| No tracking on right-side panel | We have no control over embedded external content (library databases, etc.) |
| Session-only by default | Data should not persist beyond a single browser session unless explicitly designed |
| UPEI compliance | Must align with university data policies and Canadian privacy regulations (PIPEDA) |
These constraints are non-negotiable. The system must be private by design, not private as an afterthought.
Based on the Feature List and Use Case documentation, the analytics dashboard should display:
| Metric | Purpose | Collection Method |
|---|---|---|
| Tutorial Views | Measure reach/popularity | Server-side counter (increment on page load) |
| Completion Rate | Measure engagement | Track “final slide reached” events |
| Average Quiz Score | Measure learning outcomes | Aggregate correct/incorrect responses |
| Slide Dwell Time | Identify confusing content | Timestamp difference between slide transitions |
| Quiz Item Performance | Find problematic questions | Per-question correct/incorrect ratios |
| Device Type | Understand access patterns | User-agent parsing (no fingerprinting) |
All metrics are aggregate only—we track totals and averages, never individual user journeys.
For MVP (Recommended):
Leverage the CMS platform’s built-in analytics capabilities. Both
Pressbooks (WordPress-based) and Drupal have native statistics modules
that already handle page views and basic engagement metrics in a
privacy-compliant way. This approach requires minimal development effort
and inherits the platform’s existing privacy configurations.
For Enhanced Analytics (Post-MVP):
Implement lightweight server-side aggregate counters for
tutorial-specific metrics (quiz scores, completion rates, dwell time).
Design a simple analytics dashboard that queries these aggregates. No
cookies, no user identifiers, no cross-session tracking.
Avoid: - Third-party analytics services (Google Analytics, Mixpanel) unless explicitly approved by UPEI IT - Browser fingerprinting or canvas fingerprinting techniques - Any persistent identifiers (cookies, localStorage IDs) tied to user behavior - Tracking anything on the right-side embedded content panel
This section outlines four primary approaches to collecting analytics data without compromising user privacy. Each method serves different purposes and can be combined as needed.
What it is:
The simplest form of analytics—a number in a database that increments by
1 each time an event occurs. No user identifier is stored, just the
count.
How it works:
User loads tutorial → Server receives request → UPDATE tutorials SET views = views + 1 WHERE id = X
That’s it. No cookies, no session IDs, no timestamps tied to users. Just a running total.
What we can track with aggregate counters:
| Metric | Database Field | Trigger |
|---|---|---|
| Tutorial views | view_count |
Page load |
| Tutorial completions | completion_count |
Final slide reached |
| Total quiz attempts | total_attempts |
Any answer submitted |
| Total correct answers | correct_count |
Correct answer submitted |
| Total incorrect answers | incorrect_count |
Incorrect answer submitted |
| “Give up” clicks | giveup_count |
User clicks “give up and move on” |
Derived metrics (calculated, not stored): -
Completion rate =
completion_count / view_count - Average attempts
per question = total_attempts / completion_count -
Question difficulty =
incorrect_count / total_attempts (per question)
Implementation notes: - Use database-level atomic increments to handle concurrent users - No risk of data leakage—there’s literally nothing to leak - Cannot distinguish unique vs. repeat visitors (that’s the point)
Limitations: - Cannot track user journeys or sequences - Cannot calculate time-based metrics - A single user refreshing 100 times = 100 views
What it is:
A temporary identifier that exists only in browser memory during a
single visit. When the user closes the browser tab, the session dies and
cannot be recovered.
How it works:
// Generate random session ID on page load (NOT stored in cookies or localStorage)
const sessionId = crypto.randomUUID(); // e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
// Attach to events during this session only
sendEvent({ session: sessionId, event: 'slide_view', slideId: 3 });
Critical distinction from persistent tracking: - Session ID lives in JavaScript memory only - NOT stored in cookies - NOT stored in localStorage - NOT stored server-side with any identifying info - User closes tab → session ID is gone forever
What we can track with session-based data:
| Metric | How | Privacy Impact |
|---|---|---|
| Quiz retry attempts | Count submissions per question within session | None—session dies on close |
| Time per question | Timestamp when question shown vs. answered | None—no user linkage |
| Slide dwell time | Time between slide transitions | None—aggregated after session ends |
| Tutorial completion path | Which slides visited, in what order | None—session is anonymous |
| Idle time detection | Time since last interaction | None—used for UX, not tracking |
Quiz analytics specifically: - The feature list specifies retry attempts can be 1, 2, 3, 5, or Unlimited - We can track: How many attempts did it take to get correct? Did they give up? - Stored as aggregates: “Question 3 averages 2.4 attempts before correct”
Example session data flow:
Session starts (random ID: abc123)
→ User views Slide 1 (timestamp: T0)
→ User views Slide 2 (timestamp: T1) → dwell_time_slide1 = T1 - T0
→ User attempts Quiz Q1 (incorrect)
→ User attempts Quiz Q1 (incorrect)
→ User attempts Quiz Q1 (correct) → attempts_q1 = 3
→ User completes tutorial
Session ends (tab closed)
→ Aggregate data written to DB (no session ID stored)
→ Session ID "abc123" is permanently gone
Implementation recommendation: - Generate session ID
client-side with crypto.randomUUID() - Store in JavaScript
variable only (not window.sessionStorage) - On session end (before
unload), POST aggregated stats to server - Server discards session ID
after processing, stores only aggregates
What it is:
Recording specific user actions (events) and storing them as aggregate
statistics. We track what happened, not who did
it.
Events relevant to Guide on the Side:
| Event Name | Trigger | Data Captured | NOT Captured |
|---|---|---|---|
tutorial_start |
First slide loaded | tutorial_id, timestamp, device_type | User identity |
tutorial_complete |
Final slide reached | tutorial_id, timestamp, total_time | User identity |
slide_view |
Slide transition | tutorial_id, slide_id, dwell_time | User identity |
quiz_attempt |
Answer submitted | question_id, is_correct, attempt_number | User identity |
quiz_giveup |
“Give up” clicked | question_id, attempts_before_giveup | User identity |
idle_timeout |
No interaction for X minutes | tutorial_id, slide_id, idle_duration | User identity |
Measuring tutorial effectiveness:
The whole point of analytics is answering: “Is this tutorial teaching effectively?”
Signals of a well-performing tutorial: - High completion rate (>70%) - Low average retry attempts per question - Short dwell time on instructional slides (users understand quickly) - High first-attempt correct rate on quizzes
Signals of a struggling tutorial: - High drop-off at specific slides (users leave) - Many retries or “give up” clicks on certain questions - Long dwell time (users are confused or stuck) - Low completion rate despite high view count
Idle time tracking:
If a user has no interaction for 5+ minutes, they may have walked away,
switched tabs, or given up. This is useful for: - Distinguishing “active
time” from “total time” - Identifying slides where users get stuck - Not
counting abandoned sessions as “incomplete” unfairly
Implementation:
let lastInteraction = Date.now();
document.addEventListener('click', () => { lastInteraction = Date.now(); });
document.addEventListener('keypress', () => { lastInteraction = Date.now(); });
// Check idle status periodically
setInterval(() => {
const idleTime = Date.now() - lastInteraction;
if (idleTime > 300000) { // 5 minutes
sendEvent({ event: 'idle_detected', duration: idleTime });
}
}, 60000);
What it is:
A technique to count unique visitors without storing any identifying
information. Uses one-way cryptographic hashing to create an anonymous
token that cannot be reversed to identify the user.
When you might need this: - Distinguishing “1000 views from 1000 people” vs. “1000 views from 10 people refreshing” - Understanding geographic reach (visitors outside UPEI, outside Canada) - Comparing unique reach across tutorials
How it works (if implemented):
User visits tutorial
→ Collect non-identifying attributes: screen resolution, timezone, browser language
→ Combine into string: "1920x1080|America/Halifax|en-CA"
→ Apply cryptographic hash: SHA-256("1920x1080|America/Halifax|en-CA" + daily_salt)
→ Result: "a3f2b8c9d4e5..." (irreversible, not stored with any other data)
→ Store only: unique_hash appeared today (yes/no)
Critical privacy protections: - Daily rotating salt: Hash changes every day, so users cannot be tracked across days - No raw attributes stored: Only the final hash, which cannot be reversed - Coarse granularity: We know “~500 unique visitors today” not “user X visited 3 times” - No fingerprinting: We explicitly avoid canvas fingerprinting, WebGL fingerprinting, etc.
Geographic insights (if needed):
If Melissa wants to know “Are people outside PEI using our tutorials?”, we can capture timezone or language without identifying individuals:
| Data Point | What We Learn | Privacy Risk |
|---|---|---|
| Timezone | Rough geographic region | Low (millions share each timezone) |
| Browser language | Primary language of user | Low (very coarse) |
| Screen resolution | Device type (mobile vs desktop) | Low (common values) |
Recommendation:
For MVP, skip hashed identifiers entirely. Aggregate
counters and session-based tracking give librarians everything they need
to assess tutorial effectiveness. Unique visitor counting adds
complexity and marginal value.
If the client later requests unique visitor metrics, implement with: - Daily rotating salt - Minimal attributes (timezone + language only) - Clear documentation that this is not user tracking
| Need | Recommended Method | Complexity |
|---|---|---|
| “How many times was this tutorial viewed?” | Aggregate counter | Very Low |
| “What % of viewers completed the tutorial?” | Aggregate counter (views vs completions) | Very Low |
| “Which questions do students struggle with?” | Session-based + Event tracking | Low |
| “How long do students spend on each slide?” | Session-based timing | Low |
| “How many retry attempts before correct?” | Session-based event tracking | Low |
| “How many unique people visited?” | Hashed identifier (optional) | Medium |
| “Where are our visitors from?” | Timezone/language capture (optional) | Medium |
MVP Implementation Priority: 1. Aggregate counters for views and completions (must have) 2. Session-based quiz attempt tracking (must have) 3. Event tracking for slide dwell time (should have) 4. Hashed unique visitor counting (nice to have, post-MVP)
This section evaluates analytics capabilities of platforms relevant to the Guide on the Side project. Since we’ve moved forward with Pressbooks (WordPress-based), the focus is on what’s available natively versus what requires custom implementation.
Pressbooks uses Koko Analytics as its built-in analytics solution for Enterprise and EDU networks.
What Pressbooks Provides Out-of-the-Box:
| Feature | Description | Privacy Level |
|---|---|---|
| Total visitors | Unique visitor count per book | High (cookieless option) |
| Page views | Views per page/chapter | High |
| Referrers | Where traffic comes from | High |
| Date range filtering | View stats over custom periods | N/A |
| CSV export | Download stats for reporting | N/A |
| Book downloads | Track PDF/EPUB download counts | High |
Accessing Analytics in Pressbooks: - Book-level: Dashboard → Analytics (visible to administrators) - Network-level: Stats → Analytics in Network Admin - Data retention: Configurable (default 60 months)
Configuration Options: - Exclude pageviews from specific user roles - Enable/disable cookies for unique visitor detection - Set automatic data deletion schedule
Limitations: - No quiz-level analytics (just page views) - No time-on-page or dwell time tracking - No event tracking (button clicks, form submissions) - Real-time data may lag (hourly updates, not instant)
For Self-Hosted Pressbooks (Our Situation): - Koko Analytics plugin must be installed manually - Requires setting up a cron job for stats collection - Google Analytics 4 integration available as alternative
Since Pressbooks runs on WordPress, we can use Koko Analytics directly. This is the recommended solution for Guide on the Side MVP.
Key Features:
| Feature | Free Version | Pro Version (€49/site/year) |
|---|---|---|
| Page views & visitors | √ | √ |
| Referrer tracking | √ | √ |
| Top pages report | √ | √ |
| Date range filtering | √ | √ |
| Cookieless mode | √ | √ |
| GDPR compliant | √ | √ |
| Custom event tracking | X | √ |
| CSV export | X | √ |
| Email reports | X | √ |
| Pageviews in admin lists | X | √ |
Privacy Characteristics: - No PII collected – only aggregate counts stored - No external services – all data stays on your server - Sub-500 byte tracking script – negligible performance impact - “Do Not Track” respected – users can opt out via browser setting - No cookies required – can run fully cookieless (loses returning visitor detection) - GDPR/CCPA compliant by design – no consent banner needed
Technical Implementation:
// Koko Analytics runs entirely server-side
// Tracking script is automatically injected
// Data stored in WordPress database tables
// Custom tracking (Pro only):
<?php koko_analytics_track_event('quiz_completed', ['tutorial_id' => 123]); ?>
Why Koko Analytics for Our Project: 1. Already integrated with Pressbooks ecosystem 2. Zero privacy concerns – perfect match for client requirements 3. Minimal setup – activate plugin and it works 4. No consent banners needed 5. Self-hosted – UPEI IT maintains control
Limitations for Tutorial Analytics: - Cannot track quiz performance natively - Cannot track slide dwell time - Cannot track retry attempts - These require custom implementation (see Section 2)
If Koko Analytics doesn’t meet needs, here are other WordPress-compatible options:
Matomo (formerly Piwik)
| Aspect | Details |
|---|---|
| Privacy | Self-hosted, GDPR compliant, cookieless mode available |
| Features | Full GA-like analytics, heatmaps, session recording |
| Complexity | High – requires separate server/database |
| Cost | Free (self-hosted) or €19+/month (cloud) |
| Best for | Organizations needing comprehensive analytics |
Verdict: Overkill for our needs. Koko Analytics is simpler.
Plausible Analytics
| Aspect | Details |
|---|---|
| Privacy | No cookies, no PII, GDPR compliant |
| Features | Simple dashboard, referrers, top pages, goals |
| Complexity | Low – single script tag |
| Cost | Free (self-hosted) or $9+/month (cloud) |
| Best for | Privacy-first simple analytics |
Verdict: Good alternative if Koko doesn’t work out. Requires Elixir/PostgreSQL for self-hosting.
Jetpack Stats (WordPress.com)
| Aspect | Details |
|---|---|
| Privacy | Data sent to WordPress.com servers |
| Features | Traffic, referrers, popular posts |
| Complexity | Very low – just activate plugin |
| Cost | Free (basic) or bundled with Jetpack plans |
| Best for | WordPress.com hosted sites |
Verdict: Not recommended – sends data to external servers, conflicts with UPEI privacy requirements.
LibWizard is the commercial tutorial platform Melissa referenced. Understanding its analytics helps us design ours.
What LibWizard Tracks:
| Metric | Description | Can We Replicate? |
|---|---|---|
| Submission counts | Total form/quiz completions | √Aggregate counter |
| Average quiz score | Mean correct answers | √Aggregate calculation |
| Per-question stats | Correct/incorrect per question | √Aggregate counter per question |
| User responses | Individual answer data | ⚠️ Only with session-based, not stored |
| Time-based filtering | View by date range | √Standard feature |
| Cross-tab analysis | Compare multiple tutorials | √Dashboard design |
| IP/browser/referrer | Optional PII capture | XWe explicitly avoid this |
LibWizard Privacy Options: - Can optionally record IP, referrer, browser data - “Privacy scrubbing” auto-deletes PII on rolling basis - Can designate fields to auto-clear
Key Takeaway:
LibWizard CAN track PII but makes it optional. Our approach is
stricter—we never collect PII in the first place. This is a feature, not
a limitation.
For tutorial-specific metrics that platform analytics can’t provide, we need custom code.
Minimum Viable Custom Analytics:
// Client-side: Track events during session
const analytics = {
sessionStart: Date.now(),
slideViews: [],
quizAttempts: []
};
function trackSlideView(slideId) {
analytics.slideViews.push({
slide: slideId,
timestamp: Date.now()
});
}
function trackQuizAttempt(questionId, isCorrect, attemptNumber) {
analytics.quizAttempts.push({
question: questionId,
correct: isCorrect,
attempt: attemptNumber
});
}
// On session end: Send aggregated data to server
window.addEventListener('beforeunload', () => {
navigator.sendBeacon('/api/analytics', JSON.stringify({
tutorial_id: TUTORIAL_ID,
completed: reachedFinalSlide,
total_time: Date.now() - analytics.sessionStart,
slide_count: analytics.slideViews.length,
quiz_attempts: analytics.quizAttempts.length,
quiz_correct: analytics.quizAttempts.filter(a => a.correct).length
}));
});
// Server-side: Process and store aggregates only
function process_analytics($data) {
global $wpdb;
// Increment view counter
$wpdb->query("UPDATE tutorials SET view_count = view_count + 1 WHERE id = {$data->tutorial_id}");
// Increment completion if reached end
if ($data->completed) {
$wpdb->query("UPDATE tutorials SET completion_count = completion_count + 1 WHERE id = {$data->tutorial_id}");
}
// Update quiz aggregates
$wpdb->query("UPDATE tutorials SET
total_attempts = total_attempts + {$data->quiz_attempts},
correct_answers = correct_answers + {$data->quiz_correct}
WHERE id = {$data->tutorial_id}");
// Note: We do NOT store the raw session data
// Only aggregates are persisted
}
Implementation Effort:
| Component | Effort | Priority |
|---|---|---|
| View counter | 2 hours | MVP |
| Completion tracking | 3 hours | MVP |
| Quiz aggregate stats | 4 hours | MVP |
| Slide dwell time | 4 hours | Post-MVP |
| Per-question breakdown | 6 hours | Post-MVP |
| Analytics dashboard UI | 8 hours | Post-MVP |
| Solution | Tracks Views | Tracks Quiz | Privacy | Setup Effort | Cost | Recommendation |
|---|---|---|---|---|---|---|
| Koko Analytics | √ | X | ⭐⭐⭐⭐⭐ | Very Low | Free | MVP baseline |
| Koko Pro | √ | ⚠️ Events | ⭐⭐⭐⭐⭐ | Very Low | €49/year | Consider for events |
| Matomo | √ | ⚠️ Custom | ⭐⭐⭐⭐ | High | Free | Overkill |
| Plausible | √ | ⚠️ Goals | ⭐⭐⭐⭐⭐ | Medium | $9+/mo | Good alternative |
| LibWizard | √ | √ | ⭐⭐⭐ | N/A | $$$ | Competitor only |
| Custom PHP/JS | √ | √ | ⭐⭐⭐⭐⭐ | Medium | Free | Quiz metrics |
| Google Analytics | √ | ⚠️ Events | ⭐⭐ | Low | Free | Not recommended |
Legend: - √= Native support - ⚠️ = Requires configuration/custom work - X= Not supported - ⭐ = Privacy rating (more stars = better)
┌─────────────────────────────────────────────────────────────┐
│ ANALYTICS STACK │
├─────────────────────────────────────────────────────────────┤
│ │
│ LAYER 1: Platform Analytics (Koko Analytics) │
│ ├── Page views │
│ ├── Unique visitors (cookieless) │
│ ├── Referrers │
│ └── Top pages │
│ │
│ LAYER 2: Custom Tutorial Analytics (PHP/JS) │
│ ├── Tutorial completion rate │
│ ├── Quiz attempt aggregates │
│ ├── Per-question difficulty scores │
│ └── Slide dwell time (optional) │
│ │
│ LAYER 3: Analytics Dashboard (Custom UI) │
│ ├── Librarian-facing metrics view │
│ ├── Date range filtering │
│ └── CSV export │
│ │
└─────────────────────────────────────────────────────────────┘
Implementation Order: 1. Sprint N: Install Koko Analytics, verify it works with Pressbooks 2. Sprint N+1: Implement custom aggregate counters for quizzes 3. Sprint N+2: Build analytics dashboard UI for librarians 4. Post-MVP: Add dwell time, per-question breakdowns if requested
This section provides production-ready code that can be directly integrated into the Guide on the Side plugin. All code follows WordPress coding standards and includes proper security measures.
guide-on-the-side/
├── guide-on-the-side.php # Main plugin file
├── includes/
│ ├── class-gots-analytics.php # Analytics processor
│ ├── class-gots-analytics-api.php # REST API endpoints
│ ├── class-gots-analytics-db.php # Database setup/migration
│ └── class-gots-analytics-dashboard.php # Admin dashboard
├── assets/
│ ├── js/
│ │ ├── gots-analytics.js # Client-side tracking
│ │ ├── gots-analytics.min.js # Minified version
│ │ └── gots-resume.js # Resume feature
│ └── css/
│ ├── gots-dashboard.css # Admin styles
│ └── gots-resume.css # Resume modal styles
└── templates/
└── admin-dashboard.php # Dashboard template
<?php
/**
* Guide on the Side - Database Setup
*
* Creates and manages analytics database tables.
* Run on plugin activation.
*
* @package GuideOnTheSide
* @since 1.0.0
*/
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
class GOTS_Analytics_DB {
/**
* Database version for migrations
*/
const DB_VERSION = '1.0.0';
/**
* Option name for tracking DB version
*/
const DB_VERSION_OPTION = 'gots_analytics_db_version';
/**
* Create all analytics tables
* Call this on plugin activation
*/
public static function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
// Table 1: Tutorial-level aggregate statistics
$table_tutorial_stats = $wpdb->prefix . 'gots_tutorial_stats';
$sql_tutorial_stats = "CREATE TABLE $table_tutorial_stats (
tutorial_id bigint(20) unsigned NOT NULL,
view_count int(11) unsigned NOT NULL DEFAULT 0,
completion_count int(11) unsigned NOT NULL DEFAULT 0,
total_quiz_attempts int(11) unsigned NOT NULL DEFAULT 0,
total_correct_answers int(11) unsigned NOT NULL DEFAULT 0,
total_incorrect_answers int(11) unsigned NOT NULL DEFAULT 0,
total_giveups int(11) unsigned NOT NULL DEFAULT 0,
total_time_seconds bigint(20) unsigned NOT NULL DEFAULT 0,
session_count int(11) unsigned NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (tutorial_id),
KEY idx_view_count (view_count),
KEY idx_completion_count (completion_count),
KEY idx_updated_at (updated_at)
) $charset_collate;";
// Table 2: Per-question aggregate statistics
$table_question_stats = $wpdb->prefix . 'gots_question_stats';
$sql_question_stats = "CREATE TABLE $table_question_stats (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
tutorial_id bigint(20) unsigned NOT NULL,
question_id varchar(100) NOT NULL,
question_text varchar(500) DEFAULT NULL,
attempt_count int(11) unsigned NOT NULL DEFAULT 0,
correct_count int(11) unsigned NOT NULL DEFAULT 0,
incorrect_count int(11) unsigned NOT NULL DEFAULT 0,
giveup_count int(11) unsigned NOT NULL DEFAULT 0,
total_attempts_to_correct int(11) unsigned NOT NULL DEFAULT 0,
total_time_ms bigint(20) unsigned NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY idx_tutorial_question (tutorial_id, question_id),
KEY idx_tutorial_id (tutorial_id),
KEY idx_correct_count (correct_count)
) $charset_collate;";
// Table 3: Daily aggregates for trend charts
$table_daily_stats = $wpdb->prefix . 'gots_daily_stats';
$sql_daily_stats = "CREATE TABLE $table_daily_stats (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
tutorial_id bigint(20) unsigned NOT NULL,
stat_date date NOT NULL,
views int(11) unsigned NOT NULL DEFAULT 0,
completions int(11) unsigned NOT NULL DEFAULT 0,
quiz_attempts int(11) unsigned NOT NULL DEFAULT 0,
quiz_correct int(11) unsigned NOT NULL DEFAULT 0,
total_time_seconds int(11) unsigned NOT NULL DEFAULT 0,
session_count int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY idx_tutorial_date (tutorial_id, stat_date),
KEY idx_stat_date (stat_date),
KEY idx_tutorial_id (tutorial_id)
) $charset_collate;";
// Use dbDelta for safe table creation/updates
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql_tutorial_stats);
dbDelta($sql_question_stats);
dbDelta($sql_daily_stats);
// Update version option
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
// Log success
error_log('GOTS Analytics: Database tables created/updated successfully');
}
/**
* Check if database needs updating
*/
public static function maybe_update_db() {
$installed_version = get_option(self::DB_VERSION_OPTION, '0');
if (version_compare($installed_version, self::DB_VERSION, '<')) {
self::create_tables();
}
}
/**
* Drop all analytics tables
* Call this on plugin uninstall (optional)
*/
public static function drop_tables() {
global $wpdb;
$tables = array(
$wpdb->prefix . 'gots_tutorial_stats',
$wpdb->prefix . 'gots_question_stats',
$wpdb->prefix . 'gots_daily_stats',
);
foreach ($tables as $table) {
$wpdb->query("DROP TABLE IF EXISTS $table");
}
delete_option(self::DB_VERSION_OPTION);
}
/**
* Get table names (helper for other classes)
*/
public static function get_table_names() {
global $wpdb;
return array(
'tutorial_stats' => $wpdb->prefix . 'gots_tutorial_stats',
'question_stats' => $wpdb->prefix . 'gots_question_stats',
'daily_stats' => $wpdb->prefix . 'gots_daily_stats',
);
}
}
Activation Hook (in main plugin file):
<?php
// In guide-on-the-side.php
register_activation_hook(__FILE__, 'gots_activate_plugin');
function gots_activate_plugin() {
require_once plugin_dir_path(__FILE__) . 'includes/class-gots-analytics-db.php';
GOTS_Analytics_DB::create_tables();
}
// Check for DB updates on admin init
add_action('admin_init', function() {
require_once plugin_dir_path(__FILE__) . 'includes/class-gots-analytics-db.php';
GOTS_Analytics_DB::maybe_update_db();
});
<?php
/**
* Guide on the Side - Analytics Processor
*
* Handles all analytics data processing.
* NO personally identifiable information is ever stored.
*
* @package GuideOnTheSide
* @since 1.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class GOTS_Analytics {
/**
* @var wpdb WordPress database object
*/
private $wpdb;
/**
* @var array Table names
*/
private $tables;
/**
* Constructor
*/
public function __construct() {
global $wpdb;
$this->wpdb = $wpdb;
$this->tables = array(
'tutorial' => $wpdb->prefix . 'gots_tutorial_stats',
'question' => $wpdb->prefix . 'gots_question_stats',
'daily' => $wpdb->prefix . 'gots_daily_stats',
);
}
/**
* Record a tutorial view
* Called when tutorial page loads
*
* @param int $tutorial_id WordPress post ID of the tutorial
* @return bool Success status
*/
public function record_view($tutorial_id) {
$tutorial_id = absint($tutorial_id);
if (!$tutorial_id) {
return false;
}
// Upsert tutorial stats
$result = $this->wpdb->query($this->wpdb->prepare(
"INSERT INTO {$this->tables['tutorial']}
(tutorial_id, view_count, created_at, updated_at)
VALUES (%d, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
view_count = view_count + 1,
updated_at = NOW()",
$tutorial_id
));
// Update daily stats
$this->wpdb->query($this->wpdb->prepare(
"INSERT INTO {$this->tables['daily']}
(tutorial_id, stat_date, views)
VALUES (%d, CURDATE(), 1)
ON DUPLICATE KEY UPDATE
views = views + 1",
$tutorial_id
));
return $result !== false;
}
/**
* Process complete session data
* Called via sendBeacon when user leaves tutorial
*
* @param array $data Session data from client
* @return bool Success status
*/
public function process_session($data) {
// Validate and sanitize input
$tutorial_id = absint($data['tutorial_id'] ?? 0);
if (!$tutorial_id) {
return false;
}
$completed = !empty($data['completed']);
$total_time = absint($data['total_time_seconds'] ?? 0);
$quiz_attempts = absint($data['quiz_attempts'] ?? 0);
$quiz_correct = absint($data['quiz_correct'] ?? 0);
$quiz_incorrect = absint($data['quiz_incorrect'] ?? 0);
$giveups = absint($data['giveups'] ?? 0);
// Cap values to prevent abuse
$total_time = min($total_time, 7200); // Max 2 hours
$quiz_attempts = min($quiz_attempts, 1000);
$quiz_correct = min($quiz_correct, 1000);
// Update tutorial aggregates
$this->wpdb->query($this->wpdb->prepare(
"UPDATE {$this->tables['tutorial']} SET
completion_count = completion_count + %d,
total_quiz_attempts = total_quiz_attempts + %d,
total_correct_answers = total_correct_answers + %d,
total_incorrect_answers = total_incorrect_answers + %d,
total_giveups = total_giveups + %d,
total_time_seconds = total_time_seconds + %d,
session_count = session_count + 1,
updated_at = NOW()
WHERE tutorial_id = %d",
$completed ? 1 : 0,
$quiz_attempts,
$quiz_correct,
$quiz_incorrect,
$giveups,
$total_time,
$tutorial_id
));
// Update daily stats
$this->wpdb->query($this->wpdb->prepare(
"UPDATE {$this->tables['daily']} SET
completions = completions + %d,
quiz_attempts = quiz_attempts + %d,
quiz_correct = quiz_correct + %d,
total_time_seconds = total_time_seconds + %d,
session_count = session_count + 1
WHERE tutorial_id = %d AND stat_date = CURDATE()",
$completed ? 1 : 0,
$quiz_attempts,
$quiz_correct,
$total_time,
$tutorial_id
));
// Process per-question stats
if (!empty($data['questions']) && is_array($data['questions'])) {
$this->process_question_stats($tutorial_id, $data['questions']);
}
return true;
}
/**
* Process per-question statistics
*
* @param int $tutorial_id Tutorial ID
* @param array $questions Array of question data
*/
private function process_question_stats($tutorial_id, $questions) {
foreach ($questions as $q) {
if (empty($q['id'])) {
continue;
}
$question_id = sanitize_text_field(substr($q['id'], 0, 100));
$question_text = isset($q['text']) ? sanitize_text_field(substr($q['text'], 0, 500)) : null;
$attempts = absint($q['attempts'] ?? 0);
$correct = !empty($q['correct']);
$gave_up = !empty($q['gave_up']);
$time_ms = absint($q['time_ms'] ?? 0);
// Cap values
$attempts = min($attempts, 100);
$time_ms = min($time_ms, 3600000); // Max 1 hour per question
$this->wpdb->query($this->wpdb->prepare(
"INSERT INTO {$this->tables['question']}
(tutorial_id, question_id, question_text, attempt_count, correct_count,
incorrect_count, giveup_count, total_attempts_to_correct, total_time_ms)
VALUES (%d, %s, %s, %d, %d, %d, %d, %d, %d)
ON DUPLICATE KEY UPDATE
question_text = COALESCE(VALUES(question_text), question_text),
attempt_count = attempt_count + VALUES(attempt_count),
correct_count = correct_count + VALUES(correct_count),
incorrect_count = incorrect_count + VALUES(incorrect_count),
giveup_count = giveup_count + VALUES(giveup_count),
total_attempts_to_correct = total_attempts_to_correct + VALUES(total_attempts_to_correct),
total_time_ms = total_time_ms + VALUES(total_time_ms),
updated_at = NOW()",
$tutorial_id,
$question_id,
$question_text,
$attempts,
$correct ? 1 : 0,
(!$correct && !$gave_up) ? 1 : 0,
$gave_up ? 1 : 0,
$correct ? $attempts : 0,
$time_ms
));
}
}
/**
* Get tutorial statistics for dashboard
*
* @param int $tutorial_id Tutorial ID (0 for all tutorials)
* @return object|array Stats object or array of stats
*/
public function get_tutorial_stats($tutorial_id = 0) {
$tutorial_id = absint($tutorial_id);
if ($tutorial_id) {
// Single tutorial
return $this->wpdb->get_row($this->wpdb->prepare(
"SELECT
t.tutorial_id,
p.post_title as title,
t.view_count,
t.completion_count,
CASE WHEN t.view_count > 0
THEN ROUND((t.completion_count / t.view_count) * 100, 1)
ELSE 0 END as completion_rate,
t.total_quiz_attempts,
t.total_correct_answers,
CASE WHEN t.total_quiz_attempts > 0
THEN ROUND((t.total_correct_answers / t.total_quiz_attempts) * 100, 1)
ELSE 0 END as avg_score,
t.total_giveups,
CASE WHEN t.session_count > 0
THEN ROUND(t.total_time_seconds / t.session_count / 60, 1)
ELSE 0 END as avg_time_minutes,
t.session_count,
t.updated_at
FROM {$this->tables['tutorial']} t
LEFT JOIN {$this->wpdb->posts} p ON t.tutorial_id = p.ID
WHERE t.tutorial_id = %d",
$tutorial_id
));
}
// All tutorials
return $this->wpdb->get_results(
"SELECT
t.tutorial_id,
p.post_title as title,
t.view_count,
t.completion_count,
CASE WHEN t.view_count > 0
THEN ROUND((t.completion_count / t.view_count) * 100, 1)
ELSE 0 END as completion_rate,
CASE WHEN t.total_quiz_attempts > 0
THEN ROUND((t.total_correct_answers / t.total_quiz_attempts) * 100, 1)
ELSE 0 END as avg_score,
CASE WHEN t.session_count > 0
THEN ROUND(t.total_time_seconds / t.session_count / 60, 1)
ELSE 0 END as avg_time_minutes,
t.updated_at
FROM {$this->tables['tutorial']} t
LEFT JOIN {$this->wpdb->posts} p ON t.tutorial_id = p.ID
WHERE p.post_status = 'publish'
ORDER BY t.view_count DESC"
);
}
/**
* Get question statistics for a tutorial
*
* @param int $tutorial_id Tutorial ID
* @param string $order_by Sort column (success_rate, attempts, etc.)
* @param string $order Sort direction (ASC or DESC)
* @return array Question stats
*/
public function get_question_stats($tutorial_id, $order_by = 'success_rate', $order = 'ASC') {
$tutorial_id = absint($tutorial_id);
// Whitelist order columns
$allowed_columns = array('success_rate', 'attempt_count', 'giveup_count', 'question_id');
$order_by = in_array($order_by, $allowed_columns) ? $order_by : 'success_rate';
$order = strtoupper($order) === 'DESC' ? 'DESC' : 'ASC';
return $this->wpdb->get_results($this->wpdb->prepare(
"SELECT
question_id,
question_text,
attempt_count,
correct_count,
incorrect_count,
giveup_count,
CASE WHEN attempt_count > 0
THEN ROUND((correct_count / attempt_count) * 100, 1)
ELSE 0 END as success_rate,
CASE WHEN correct_count > 0
THEN ROUND(total_attempts_to_correct / correct_count, 1)
ELSE 0 END as avg_attempts_to_correct,
CASE WHEN attempt_count > 0
THEN ROUND(total_time_ms / attempt_count / 1000, 1)
ELSE 0 END as avg_time_seconds
FROM {$this->tables['question']}
WHERE tutorial_id = %d
ORDER BY $order_by $order",
$tutorial_id
));
}
/**
* Get daily statistics for trend charts
*
* @param int $tutorial_id Tutorial ID (0 for all)
* @param int $days Number of days to retrieve
* @param string $start_date Optional start date (Y-m-d)
* @param string $end_date Optional end date (Y-m-d)
* @return array Daily stats
*/
public function get_daily_stats($tutorial_id = 0, $days = 30, $start_date = null, $end_date = null) {
$tutorial_id = absint($tutorial_id);
$days = min(absint($days), 365); // Max 1 year
// Build date conditions
if ($start_date && $end_date) {
$start = sanitize_text_field($start_date);
$end = sanitize_text_field($end_date);
$date_condition = $this->wpdb->prepare(
"stat_date BETWEEN %s AND %s",
$start, $end
);
} else {
$date_condition = $this->wpdb->prepare(
"stat_date >= DATE_SUB(CURDATE(), INTERVAL %d DAY)",
$days
);
}
if ($tutorial_id) {
return $this->wpdb->get_results($this->wpdb->prepare(
"SELECT
stat_date,
views,
completions,
quiz_attempts,
quiz_correct,
CASE WHEN session_count > 0
THEN ROUND(total_time_seconds / session_count / 60, 1)
ELSE 0 END as avg_time_minutes
FROM {$this->tables['daily']}
WHERE tutorial_id = %d AND $date_condition
ORDER BY stat_date ASC",
$tutorial_id
));
}
// Aggregate across all tutorials
return $this->wpdb->get_results(
"SELECT
stat_date,
SUM(views) as views,
SUM(completions) as completions,
SUM(quiz_attempts) as quiz_attempts,
SUM(quiz_correct) as quiz_correct
FROM {$this->tables['daily']}
WHERE $date_condition
GROUP BY stat_date
ORDER BY stat_date ASC"
);
}
/**
* Get summary statistics for dashboard header
*
* @return object Summary stats
*/
public function get_summary_stats() {
return $this->wpdb->get_row(
"SELECT
SUM(view_count) as total_views,
SUM(completion_count) as total_completions,
CASE WHEN SUM(view_count) > 0
THEN ROUND((SUM(completion_count) / SUM(view_count)) * 100, 1)
ELSE 0 END as overall_completion_rate,
CASE WHEN SUM(total_quiz_attempts) > 0
THEN ROUND((SUM(total_correct_answers) / SUM(total_quiz_attempts)) * 100, 1)
ELSE 0 END as overall_avg_score,
CASE WHEN SUM(session_count) > 0
THEN ROUND(SUM(total_time_seconds) / SUM(session_count) / 60, 1)
ELSE 0 END as overall_avg_time,
COUNT(*) as tutorial_count
FROM {$this->tables['tutorial']}"
);
}
/**
* Export tutorial stats as CSV
*
* @param int $tutorial_id Tutorial ID (0 for all)
* @return string CSV content
*/
public function export_csv($tutorial_id = 0) {
$tutorial_id = absint($tutorial_id);
$output = fopen('php://temp', 'r+');
if ($tutorial_id) {
// Export single tutorial with questions
$stats = $this->get_tutorial_stats($tutorial_id);
$questions = $this->get_question_stats($tutorial_id);
// Tutorial summary
fputcsv($output, array('Tutorial Analytics Export'));
fputcsv($output, array('Generated', current_time('mysql')));
fputcsv($output, array(''));
fputcsv($output, array('Tutorial', $stats->title ?? 'Unknown'));
fputcsv($output, array('Views', $stats->view_count ?? 0));
fputcsv($output, array('Completions', $stats->completion_count ?? 0));
fputcsv($output, array('Completion Rate', ($stats->completion_rate ?? 0) . '%'));
fputcsv($output, array('Avg Score', ($stats->avg_score ?? 0) . '%'));
fputcsv($output, array('Avg Time (min)', $stats->avg_time_minutes ?? 0));
fputcsv($output, array(''));
// Question breakdown
fputcsv($output, array('Question Performance'));
fputcsv($output, array('Question ID', 'Question', 'Attempts', 'Success Rate', 'Avg Attempts', 'Give-ups'));
foreach ($questions as $q) {
fputcsv($output, array(
$q->question_id,
$q->question_text,
$q->attempt_count,
$q->success_rate . '%',
$q->avg_attempts_to_correct,
$q->giveup_count
));
}
} else {
// Export all tutorials
$tutorials = $this->get_tutorial_stats();
fputcsv($output, array('Tutorial', 'Views', 'Completions', 'Completion Rate', 'Avg Score', 'Avg Time (min)'));
foreach ($tutorials as $t) {
fputcsv($output, array(
$t->title,
$t->view_count,
$t->completion_count,
$t->completion_rate . '%',
$t->avg_score . '%',
$t->avg_time_minutes
));
}
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
return $csv;
}
}
<?php
/**
* Guide on the Side - REST API for Analytics
*
* Provides endpoints for client-side tracking and dashboard data.
*
* @package GuideOnTheSide
* @since 1.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class GOTS_Analytics_API {
/**
* API namespace
*/
const NAMESPACE = 'gots/v1';
/**
* Register REST routes
*/
public static function register_routes() {
// Public endpoint: Record analytics (no auth required)
register_rest_route(self::NAMESPACE, '/analytics', array(
'methods' => 'POST',
'callback' => array(__CLASS__, 'handle_analytics'),
'permission_callback' => '__return_true', // Public for anonymous users
));
// Public endpoint: Record view (no auth required)
register_rest_route(self::NAMESPACE, '/analytics/view', array(
'methods' => 'POST',
'callback' => array(__CLASS__, 'handle_view'),
'permission_callback' => '__return_true',
));
// Admin endpoint: Get stats (requires edit_posts capability)
register_rest_route(self::NAMESPACE, '/analytics/stats', array(
'methods' => 'GET',
'callback' => array(__CLASS__, 'get_stats'),
'permission_callback' => function() {
return current_user_can('edit_posts');
},
));
// Admin endpoint: Get stats for specific tutorial
register_rest_route(self::NAMESPACE, '/analytics/stats/(?P<id>\d+)', array(
'methods' => 'GET',
'callback' => array(__CLASS__, 'get_tutorial_stats'),
'permission_callback' => function() {
return current_user_can('edit_posts');
},
'args' => array(
'id' => array(
'validate_callback' => function($param) {
return is_numeric($param);
}
),
),
));
// Admin endpoint: Export CSV
register_rest_route(self::NAMESPACE, '/analytics/export', array(
'methods' => 'GET',
'callback' => array(__CLASS__, 'export_csv'),
'permission_callback' => function() {
return current_user_can('edit_posts');
},
));
}
/**
* Handle view recording
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public static function handle_view(WP_REST_Request $request) {
$data = $request->get_json_params();
$tutorial_id = absint($data['tutorial_id'] ?? 0);
if (!$tutorial_id) {
return new WP_REST_Response(array('error' => 'Invalid tutorial ID'), 400);
}
// Verify tutorial exists
if (!get_post($tutorial_id)) {
return new WP_REST_Response(array('error' => 'Tutorial not found'), 404);
}
$analytics = new GOTS_Analytics();
$analytics->record_view($tutorial_id);
return new WP_REST_Response(array('status' => 'ok'), 200);
}
/**
* Handle session analytics data
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public static function handle_analytics(WP_REST_Request $request) {
$data = $request->get_json_params();
// Basic validation
if (empty($data['tutorial_id'])) {
return new WP_REST_Response(array('error' => 'Missing tutorial_id'), 400);
}
// Rate limiting check (optional - implement if needed)
// $ip_hash = wp_hash($_SERVER['REMOTE_ADDR'] ?? 'unknown');
// if (get_transient('gots_rate_' . $ip_hash)) {
// return new WP_REST_Response(array('error' => 'Rate limited'), 429);
// }
// set_transient('gots_rate_' . $ip_hash, 1, 1); // 1 second cooldown
$analytics = new GOTS_Analytics();
$analytics->process_session($data);
// Minimal response (sendBeacon doesn't wait anyway)
return new WP_REST_Response(array('status' => 'ok'), 200);
}
/**
* Get all tutorial stats (admin)
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public static function get_stats(WP_REST_Request $request) {
$analytics = new GOTS_Analytics();
$data = array(
'summary' => $analytics->get_summary_stats(),
'tutorials' => $analytics->get_tutorial_stats(),
'daily' => $analytics->get_daily_stats(0, 30),
);
return new WP_REST_Response($data, 200);
}
/**
* Get stats for specific tutorial (admin)
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public static function get_tutorial_stats(WP_REST_Request $request) {
$tutorial_id = absint($request->get_param('id'));
$analytics = new GOTS_Analytics();
$data = array(
'stats' => $analytics->get_tutorial_stats($tutorial_id),
'questions' => $analytics->get_question_stats($tutorial_id),
'daily' => $analytics->get_daily_stats($tutorial_id, 30),
);
return new WP_REST_Response($data, 200);
}
/**
* Export CSV (admin)
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public static function export_csv(WP_REST_Request $request) {
$tutorial_id = absint($request->get_param('tutorial_id') ?? 0);
$analytics = new GOTS_Analytics();
$csv = $analytics->export_csv($tutorial_id);
$response = new WP_REST_Response($csv, 200);
$response->header('Content-Type', 'text/csv');
$response->header('Content-Disposition', 'attachment; filename="gots-analytics-' . date('Y-m-d') . '.csv"');
return $response;
}
}
// Register routes on REST API init
add_action('rest_api_init', array('GOTS_Analytics_API', 'register_routes'));
/**
* Guide on the Side - Client-Side Analytics
*
* Tracks user progress within a single session.
* Data stored in memory and localStorage (for resume feature).
*
* NO data sent to external services.
* NO cookies set.
* NO fingerprinting.
*
* @package GuideOnTheSide
* @since 1.0.0
*/
(function(window, document) {
'use strict';
/**
* Main Analytics Class
*/
class GOTSAnalytics {
/**
* Initialize analytics
*
* @param {Object} config Configuration object
* @param {number} config.tutorialId - WordPress post ID
* @param {string} config.tutorialSlug - Tutorial slug for localStorage key
* @param {string} config.apiEndpoint - REST API base URL
* @param {number} config.totalSlides - Total number of slides
*/
constructor(config) {
this.config = {
tutorialId: config.tutorialId || 0,
tutorialSlug: config.tutorialSlug || 'tutorial',
apiEndpoint: config.apiEndpoint || '/wp-json/gots/v1',
totalSlides: config.totalSlides || 0,
resumeExpiry: config.resumeExpiry || 24 * 60 * 60 * 1000, // 24 hours
};
this.storageKey = `gots_progress_${this.config.tutorialSlug}`;
// Session data (memory only)
this.session = {
startTime: Date.now(),
currentSlide: 0,
maxSlideReached: 0,
slideViews: {},
questions: {},
completed: false,
viewRecorded: false,
};
// Bind methods
this.handleUnload = this.handleUnload.bind(this);
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
// Initialize
this.init();
}
/**
* Initialize tracking
*/
init() {
// Load saved progress
this.loadProgress();
// Record view (once per page load)
this.recordView();
// Set up unload handlers
this.setupUnloadHandlers();
// Log initialization (dev only)
if (window.GOTS_DEBUG) {
console.log('GOTS Analytics initialized:', this.config);
}
}
/**
* Record tutorial view
*/
recordView() {
if (this.session.viewRecorded || !this.config.tutorialId) {
return;
}
this.session.viewRecorded = true;
this.sendData('/analytics/view', {
tutorial_id: this.config.tutorialId,
});
}
/**
* Track slide navigation
*
* @param {number|string} slideId - Slide identifier
* @param {string} slideTitle - Optional slide title
*/
trackSlideView(slideId, slideTitle = null) {
const now = Date.now();
const slideKey = String(slideId);
// Calculate dwell time on previous slide
if (this.session.currentSlide && this.session.slideViews[this.session.currentSlide]) {
const prevSlide = this.session.slideViews[this.session.currentSlide];
prevSlide.dwellTime = (prevSlide.dwellTime || 0) + (now - prevSlide.lastEntered);
}
// Record new slide view
if (!this.session.slideViews[slideKey]) {
this.session.slideViews[slideKey] = {
visits: 0,
dwellTime: 0,
title: slideTitle,
};
}
this.session.slideViews[slideKey].visits++;
this.session.slideViews[slideKey].lastEntered = now;
this.session.currentSlide = slideKey;
// Track max slide reached
const slideNum = parseInt(slideId, 10);
if (!isNaN(slideNum) && slideNum > this.session.maxSlideReached) {
this.session.maxSlideReached = slideNum;
}
// Save progress for resume feature
this.saveProgress();
}
/**
* Track quiz question attempt
*
* @param {string} questionId - Question identifier
* @param {boolean} isCorrect - Whether answer was correct
* @param {Object} options - Additional options
* @param {string} options.questionText - Question text for reporting
* @param {number} options.attemptNumber - Current attempt number
*/
trackQuizAttempt(questionId, isCorrect, options = {}) {
const questionKey = String(questionId);
if (!this.session.questions[questionKey]) {
this.session.questions[questionKey] = {
text: options.questionText || null,
attempts: 0,
correct: false,
gaveUp: false,
startTime: Date.now(),
correctTime: null,
};
}
const q = this.session.questions[questionKey];
q.attempts = options.attemptNumber || q.attempts + 1;
if (isCorrect && !q.correct) {
q.correct = true;
q.correctTime = Date.now();
}
this.saveProgress();
}
/**
* Track when user gives up on a question
*
* @param {string} questionId - Question identifier
*/
trackGiveUp(questionId) {
const questionKey = String(questionId);
if (this.session.questions[questionKey]) {
this.session.questions[questionKey].gaveUp = true;
}
this.saveProgress();
}
/**
* Mark tutorial as completed
*/
markCompleted() {
if (this.session.completed) {
return;
}
this.session.completed = true;
this.session.completedTime = Date.now();
this.saveProgress();
// Send completion immediately (don't wait for unload)
this.sendSessionData();
}
/**
* Save progress to localStorage
*/
saveProgress() {
try {
const data = {
version: 1,
tutorialId: this.config.tutorialId,
currentSlide: this.session.currentSlide,
maxSlideReached: this.session.maxSlideReached,
questions: this.session.questions,
completed: this.session.completed,
savedAt: Date.now(),
};
localStorage.setItem(this.storageKey, JSON.stringify(data));
} catch (e) {
// localStorage might be full or disabled - continue silently
if (window.GOTS_DEBUG) {
console.warn('GOTS: Could not save progress:', e);
}
}
}
/**
* Load saved progress from localStorage
*
* @returns {boolean} Whether progress was restored
*/
loadProgress() {
try {
const saved = localStorage.getItem(this.storageKey);
if (!saved) {
return false;
}
const data = JSON.parse(saved);
// Check version and tutorial match
if (data.version !== 1 || data.tutorialId !== this.config.tutorialId) {
this.clearProgress();
return false;
}
// Check expiry
if (Date.now() - data.savedAt > this.config.resumeExpiry) {
this.clearProgress();
return false;
}
// Restore session data
this.session.currentSlide = data.currentSlide || 0;
this.session.maxSlideReached = data.maxSlideReached || 0;
this.session.questions = data.questions || {};
this.session.completed = data.completed || false;
return true;
} catch (e) {
this.clearProgress();
return false;
}
}
/**
* Check if saved progress exists
*
* @returns {Object|null} Progress info or null
*/
getSavedProgress() {
try {
const saved = localStorage.getItem(this.storageKey);
if (!saved) {
return null;
}
const data = JSON.parse(saved);
// Validate
if (data.version !== 1 ||
data.tutorialId !== this.config.tutorialId ||
Date.now() - data.savedAt > this.config.resumeExpiry) {
return null;
}
return {
currentSlide: data.currentSlide,
maxSlideReached: data.maxSlideReached,
completed: data.completed,
savedAt: new Date(data.savedAt),
};
} catch (e) {
return null;
}
}
/**
* Clear saved progress
*/
clearProgress() {
try {
localStorage.removeItem(this.storageKey);
} catch (e) {
// Ignore errors
}
}
/**
* Set up page unload handlers
*/
setupUnloadHandlers() {
// visibilitychange is more reliable on mobile
document.addEventListener('visibilitychange', this.handleVisibilityChange);
// pagehide is the standard event for page unload
window.addEventListener('pagehide', this.handleUnload);
// beforeunload as fallback (some browsers)
window.addEventListener('beforeunload', this.handleUnload);
}
/**
* Handle visibility change
*/
handleVisibilityChange() {
if (document.visibilityState === 'hidden') {
this.sendSessionData();
}
}
/**
* Handle page unload
*
* @param {Event} event
*/
handleUnload(event) {
this.sendSessionData();
}
/**
* Send session data to server
*/
sendSessionData() {
// Calculate final dwell time for current slide
if (this.session.currentSlide && this.session.slideViews[this.session.currentSlide]) {
const current = this.session.slideViews[this.session.currentSlide];
current.dwellTime = (current.dwellTime || 0) + (Date.now() - current.lastEntered);
}
// Build questions array
const questions = Object.entries(this.session.questions).map(([id, data]) => ({
id: id,
text: data.text,
attempts: data.attempts,
correct: data.correct,
gave_up: data.gaveUp,
time_ms: data.correctTime ? data.correctTime - data.startTime : Date.now() - data.startTime,
}));
// Calculate totals
const totalTime = Math.round((Date.now() - this.session.startTime) / 1000);
const quizAttempts = questions.reduce((sum, q) => sum + q.attempts, 0);
const quizCorrect = questions.filter(q => q.correct).length;
const quizIncorrect = questions.filter(q => !q.correct && !q.gave_up).length;
const giveups = questions.filter(q => q.gave_up).length;
const payload = {
tutorial_id: this.config.tutorialId,
completed: this.session.completed,
total_time_seconds: totalTime,
max_slide_reached: this.session.maxSlideReached,
quiz_attempts: quizAttempts,
quiz_correct: quizCorrect,
quiz_incorrect: quizIncorrect,
giveups: giveups,
questions: questions,
};
this.sendData('/analytics', payload);
}
/**
* Send data to API using sendBeacon
*
* @param {string} endpoint - API endpoint path
* @param {Object} data - Data to send
*/
sendData(endpoint, data) {
const url = this.config.apiEndpoint + endpoint;
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
// Use sendBeacon for reliable delivery on page unload
if (navigator.sendBeacon) {
navigator.sendBeacon(url, blob);
} else {
// Fallback to fetch with keepalive
fetch(url, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {
// Silently fail - analytics should never break the tutorial
});
}
}
/**
* Destroy instance and clean up
*/
destroy() {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('pagehide', this.handleUnload);
window.removeEventListener('beforeunload', this.handleUnload);
}
}
/**
* Resume Prompt Manager
*/
class GOTSResumePrompt {
/**
* Show resume prompt
*
* @param {Object} progress - Saved progress info
* @param {Function} onResume - Callback when user chooses to resume
* @param {Function} onStartOver - Callback when user chooses to start over
*/
static show(progress, onResume, onStartOver) {
// Create modal
const modal = document.createElement('div');
modal.className = 'gots-resume-modal';
modal.innerHTML = `
<div class="gots-resume-overlay"></div>
<div class="gots-resume-content" role="dialog" aria-modal="true" aria-labelledby="gots-resume-title">
<h2 id="gots-resume-title">Welcome Back! 👋</h2>
<p>You have saved progress on this tutorial.</p>
<p class="gots-resume-info">You were on <strong>Slide ${progress.currentSlide}</strong></p>
<div class="gots-resume-buttons">
<button type="button" class="gots-btn gots-btn-primary" id="gots-resume-continue">
Resume Tutorial
</button>
<button type="button" class="gots-btn gots-btn-secondary" id="gots-resume-restart">
Start Over
</button>
</div>
<p class="gots-resume-note">
Your progress is stored only on this device and can be cleared anytime.
</p>
</div>
`;
document.body.appendChild(modal);
// Focus management
const continueBtn = modal.querySelector('#gots-resume-continue');
const restartBtn = modal.querySelector('#gots-resume-restart');
continueBtn.focus();
// Event handlers
const handleResume = () => {
modal.remove();
if (typeof onResume === 'function') {
onResume(progress.currentSlide);
}
};
const handleStartOver = () => {
modal.remove();
if (typeof onStartOver === 'function') {
onStartOver();
}
};
continueBtn.addEventListener('click', handleResume);
restartBtn.addEventListener('click', handleStartOver);
// Close on escape
const handleKeydown = (e) => {
if (e.key === 'Escape') {
handleStartOver();
}
};
document.addEventListener('keydown', handleKeydown);
// Cleanup function
modal._cleanup = () => {
document.removeEventListener('keydown', handleKeydown);
};
}
}
// Expose to global scope
window.GOTSAnalytics = GOTSAnalytics;
window.GOTSResumePrompt = GOTSResumePrompt;
})(window, document);
/**
* USAGE EXAMPLE:
*
* // Initialize analytics when tutorial loads
* document.addEventListener('DOMContentLoaded', function() {
*
* // Create analytics instance
* window.gotsAnalytics = new GOTSAnalytics({
* tutorialId: GOTS_CONFIG.tutorialId, // From wp_localize_script
* tutorialSlug: GOTS_CONFIG.tutorialSlug,
* apiEndpoint: GOTS_CONFIG.apiEndpoint,
* totalSlides: GOTS_CONFIG.totalSlides,
* });
*
* // Check for saved progress
* const savedProgress = window.gotsAnalytics.getSavedProgress();
*
* if (savedProgress && savedProgress.currentSlide > 1) {
* GOTSResumePrompt.show(
* savedProgress,
* function(slideNum) {
* // Resume: navigate to saved slide
* navigateToSlide(slideNum);
* },
* function() {
* // Start over: clear progress and go to slide 1
* window.gotsAnalytics.clearProgress();
* navigateToSlide(1);
* }
* );
* }
*
* // Track slide views (call this when user navigates)
* function onSlideChange(slideId, slideTitle) {
* window.gotsAnalytics.trackSlideView(slideId, slideTitle);
* }
*
* // Track quiz attempts (call this when user submits answer)
* function onQuizSubmit(questionId, isCorrect, questionText, attemptNum) {
* window.gotsAnalytics.trackQuizAttempt(questionId, isCorrect, {
* questionText: questionText,
* attemptNumber: attemptNum,
* });
* }
*
* // Track give-up (call this when user skips question)
* function onQuizGiveUp(questionId) {
* window.gotsAnalytics.trackGiveUp(questionId);
* }
*
* // Mark completion (call this when user finishes tutorial)
* function onTutorialComplete() {
* window.gotsAnalytics.markCompleted();
* }
* });
*/
<?php
/**
* Enqueue analytics scripts and pass configuration to JavaScript
* Add this to your main plugin file or functions
*/
add_action('wp_enqueue_scripts', 'gots_enqueue_analytics_scripts');
function gots_enqueue_analytics_scripts() {
// Only load on tutorial pages
if (!is_singular('gots_tutorial')) {
return;
}
global $post;
// Enqueue analytics script
wp_enqueue_script(
'gots-analytics',
plugin_dir_url(__FILE__) . 'assets/js/gots-analytics.js',
array(), // No dependencies
GOTS_VERSION,
true // Load in footer
);
// Enqueue resume modal styles
wp_enqueue_style(
'gots-resume',
plugin_dir_url(__FILE__) . 'assets/css/gots-resume.css',
array(),
GOTS_VERSION
);
// Get tutorial slide count (adjust based on your data structure)
$total_slides = get_post_meta($post->ID, '_gots_slide_count', true) ?: 0;
// Pass configuration to JavaScript
wp_localize_script('gots-analytics', 'GOTS_CONFIG', array(
'tutorialId' => $post->ID,
'tutorialSlug' => $post->post_name,
'apiEndpoint' => rest_url('gots/v1'),
'totalSlides' => intval($total_slides),
'nonce' => wp_create_nonce('wp_rest'), // For authenticated endpoints
));
}
/**
* Enqueue admin dashboard scripts and styles
*/
add_action('admin_enqueue_scripts', 'gots_enqueue_admin_scripts');
function gots_enqueue_admin_scripts($hook) {
// Only load on our dashboard page
if (strpos($hook, 'gots-analytics') === false) {
return;
}
// Dashboard styles
wp_enqueue_style(
'gots-dashboard',
plugin_dir_url(__FILE__) . 'assets/css/gots-dashboard.css',
array(),
GOTS_VERSION
);
// Chart.js for visualizations (optional)
wp_enqueue_script(
'chartjs',
'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js',
array(),
'4.4.1',
true
);
// Dashboard JavaScript
wp_enqueue_script(
'gots-dashboard',
plugin_dir_url(__FILE__) . 'assets/js/gots-dashboard.js',
array('chartjs'),
GOTS_VERSION,
true
);
wp_localize_script('gots-dashboard', 'GOTS_ADMIN', array(
'apiEndpoint' => rest_url('gots/v1'),
'nonce' => wp_create_nonce('wp_rest'),
));
}
<?php
/**
* Koko Analytics Integration
*
* Koko Analytics is typically pre-installed with Pressbooks.
* These functions help integrate with it if available.
*/
/**
* Check if Koko Analytics is active
*
* @return bool
*/
function gots_koko_analytics_active() {
return class_exists('KokoAnalytics\Plugin') ||
function_exists('koko_analytics');
}
/**
* Get Koko Analytics stats for a page
* Note: Koko tracks by URL, not post ID
*
* @param int $post_id Post ID
* @param int $days Number of days
* @return array|null Stats or null if not available
*/
function gots_get_koko_stats($post_id, $days = 30) {
if (!gots_koko_analytics_active()) {
return null;
}
global $wpdb;
$url = get_permalink($post_id);
$path = wp_parse_url($url, PHP_URL_PATH);
// Koko stores stats in koko_analytics_site_stats and koko_analytics_post_stats
$table = $wpdb->prefix . 'koko_analytics_post_stats';
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
return null;
}
$stats = $wpdb->get_row($wpdb->prepare(
"SELECT
SUM(visitors) as visitors,
SUM(pageviews) as pageviews
FROM $table
WHERE post_id = %d
AND date >= DATE_SUB(CURDATE(), INTERVAL %d DAY)",
$post_id,
$days
));
return $stats;
}
/**
* Recommended Koko Analytics settings for privacy
* Apply these via wp-admin or programmatically
*/
function gots_recommended_koko_settings() {
return array(
// Use cookieless tracking (no consent needed)
'use_cookie' => 0,
// Respect Do Not Track header
'honor_dnt' => 1,
// Data retention (days) - 0 for unlimited
'prune_data_after_days' => 365,
// Exclude logged-in users from tracking
'exclude_user_roles' => array('administrator', 'editor'),
);
}
/**
* Display combined stats (Koko + Custom) in dashboard
*
* @param int $tutorial_id Tutorial post ID
* @return array Combined stats
*/
function gots_get_combined_stats($tutorial_id) {
$analytics = new GOTS_Analytics();
$custom_stats = $analytics->get_tutorial_stats($tutorial_id);
$koko_stats = gots_get_koko_stats($tutorial_id, 30);
return array(
// From Koko Analytics (if available)
'unique_visitors' => $koko_stats->visitors ?? null,
'pageviews' => $koko_stats->pageviews ?? $custom_stats->view_count ?? 0,
// From Custom Analytics
'views' => $custom_stats->view_count ?? 0,
'completions' => $custom_stats->completion_count ?? 0,
'completion_rate' => $custom_stats->completion_rate ?? 0,
'avg_score' => $custom_stats->avg_score ?? 0,
'avg_time' => $custom_stats->avg_time_minutes ?? 0,
);
}
/**
* Guide on the Side - Resume Modal Styles
*
* Styled with UPEI branding colors
*/
/* Modal Overlay */
.gots-resume-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.gots-resume-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
/* Modal Content */
.gots-resume-content {
position: relative;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
padding: 2rem;
max-width: 420px;
width: 100%;
text-align: center;
animation: gots-modal-appear 0.3s ease-out;
}
@keyframes gots-modal-appear {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Typography */
.gots-resume-content h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.gots-resume-content p {
margin: 0 0 0.5rem 0;
color: #666666;
font-size: 1rem;
line-height: 1.5;
}
.gots-resume-info {
background: #f5f5f5;
padding: 0.75rem 1rem;
border-radius: 8px;
margin: 1rem 0 !important;
}
.gots-resume-info strong {
color: #8C2004; /* UPEI Red */
}
/* Buttons */
.gots-resume-buttons {
display: flex;
gap: 0.75rem;
justify-content: center;
margin: 1.5rem 0 1rem 0;
}
.gots-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.gots-btn:focus {
outline: 2px solid #8C2004;
outline-offset: 2px;
}
.gots-btn-primary {
background: #8C2004; /* UPEI Red */
color: #ffffff;
}
.gots-btn-primary:hover {
background: #6d1903;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(140, 32, 4, 0.3);
}
.gots-btn-secondary {
background: #e5e5e5;
color: #333333;
}
.gots-btn-secondary:hover {
background: #d5d5d5;
}
/* Note */
.gots-resume-note {
font-size: 0.813rem !important;
color: #888888 !important;
margin-top: 1rem !important;
}
/* Responsive */
@media (max-width: 480px) {
.gots-resume-content {
padding: 1.5rem;
}
.gots-resume-buttons {
flex-direction: column;
}
.gots-btn {
width: 100%;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.gots-btn-primary {
border: 2px solid #000000;
}
.gots-btn-secondary {
border: 2px solid #333333;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.gots-resume-content {
animation: none;
}
.gots-btn {
transition: none;
}
}
Create these tables in the WordPress database:
-- Tutorial-level aggregate statistics
CREATE TABLE wp_gots_tutorial_stats (
tutorial_id BIGINT PRIMARY KEY,
view_count INT DEFAULT 0,
completion_count INT DEFAULT 0,
total_quiz_attempts INT DEFAULT 0,
total_correct_answers INT DEFAULT 0,
total_giveups INT DEFAULT 0,
total_time_seconds BIGINT DEFAULT 0, -- Aggregate of all session times
session_count INT DEFAULT 0, -- For calculating averages
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Derived metrics (calculated on read, not stored)
-- completion_rate = completion_count / view_count
-- avg_score = total_correct_answers / total_quiz_attempts
-- avg_time = total_time_seconds / session_count
FOREIGN KEY (tutorial_id) REFERENCES wp_posts(ID) ON DELETE CASCADE
);
-- Per-question aggregate statistics (for identifying difficult questions)
CREATE TABLE wp_gots_question_stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tutorial_id BIGINT NOT NULL,
question_id VARCHAR(50) NOT NULL, -- Slide/question identifier
question_text VARCHAR(500), -- For dashboard display
attempt_count INT DEFAULT 0,
correct_count INT DEFAULT 0,
incorrect_count INT DEFAULT 0,
giveup_count INT DEFAULT 0,
total_attempts_to_correct INT DEFAULT 0, -- Sum of attempts before correct
UNIQUE KEY unique_question (tutorial_id, question_id),
FOREIGN KEY (tutorial_id) REFERENCES wp_posts(ID) ON DELETE CASCADE
);
-- Daily aggregates for trend analysis (optional, for charts)
CREATE TABLE wp_gots_daily_stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tutorial_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
views INT DEFAULT 0,
completions INT DEFAULT 0,
UNIQUE KEY unique_daily (tutorial_id, stat_date),
FOREIGN KEY (tutorial_id) REFERENCES wp_posts(ID) ON DELETE CASCADE
);
Why this schema works for librarians: - One row per tutorial = fast dashboard queries - Pre-aggregated data = instant loading, no heavy calculations - Question-level stats = identify problem areas at a glance - Daily stats = simple trend charts without complex queries
File:
includes/class-gots-analytics.php
<?php
/**
* Guide on the Side - Analytics Handler
*
* Processes anonymous session data and updates aggregate counters.
* NO personally identifiable information is stored.
*/
class GOTS_Analytics {
private $wpdb;
public function __construct() {
global $wpdb;
$this->wpdb = $wpdb;
}
/**
* Increment view counter when tutorial loads
* Called via AJAX on first page load
*/
public function record_view($tutorial_id) {
$tutorial_id = absint($tutorial_id);
// Upsert: insert if not exists, update if exists
$this->wpdb->query($this->wpdb->prepare(
"INSERT INTO {$this->wpdb->prefix}gots_tutorial_stats
(tutorial_id, view_count, updated_at)
VALUES (%d, 1, NOW())
ON DUPLICATE KEY UPDATE
view_count = view_count + 1,
updated_at = NOW()",
$tutorial_id
));
// Also record daily stat
$this->wpdb->query($this->wpdb->prepare(
"INSERT INTO {$this->wpdb->prefix}gots_daily_stats
(tutorial_id, stat_date, views)
VALUES (%d, CURDATE(), 1)
ON DUPLICATE KEY UPDATE views = views + 1",
$tutorial_id
));
return true;
}
/**
* Process session data when user completes or leaves tutorial
* Called via navigator.sendBeacon() on page unload
*
* @param array $session_data Aggregated session data (no PII)
*/
public function process_session($session_data) {
$tutorial_id = absint($session_data['tutorial_id']);
$completed = (bool) $session_data['completed'];
$total_time = absint($session_data['total_time_seconds']);
$quiz_attempts = absint($session_data['quiz_attempts']);
$quiz_correct = absint($session_data['quiz_correct']);
$giveups = absint($session_data['giveups']);
// Update tutorial aggregates
$this->wpdb->query($this->wpdb->prepare(
"UPDATE {$this->wpdb->prefix}gots_tutorial_stats SET
completion_count = completion_count + %d,
total_quiz_attempts = total_quiz_attempts + %d,
total_correct_answers = total_correct_answers + %d,
total_giveups = total_giveups + %d,
total_time_seconds = total_time_seconds + %d,
session_count = session_count + 1,
updated_at = NOW()
WHERE tutorial_id = %d",
$completed ? 1 : 0,
$quiz_attempts,
$quiz_correct,
$giveups,
$total_time,
$tutorial_id
));
// Update daily completion count
if ($completed) {
$this->wpdb->query($this->wpdb->prepare(
"UPDATE {$this->wpdb->prefix}gots_daily_stats
SET completions = completions + 1
WHERE tutorial_id = %d AND stat_date = CURDATE()",
$tutorial_id
));
}
// Process per-question stats if provided
if (!empty($session_data['questions'])) {
$this->process_question_stats($tutorial_id, $session_data['questions']);
}
return true;
}
/**
* Update per-question statistics
*/
private function process_question_stats($tutorial_id, $questions) {
foreach ($questions as $q) {
$question_id = sanitize_text_field($q['id']);
$attempts = absint($q['attempts']);
$correct = (bool) $q['correct'];
$gave_up = (bool) $q['gave_up'];
$this->wpdb->query($this->wpdb->prepare(
"INSERT INTO {$this->wpdb->prefix}gots_question_stats
(tutorial_id, question_id, attempt_count, correct_count, incorrect_count, giveup_count, total_attempts_to_correct)
VALUES (%d, %s, %d, %d, %d, %d, %d)
ON DUPLICATE KEY UPDATE
attempt_count = attempt_count + %d,
correct_count = correct_count + %d,
incorrect_count = incorrect_count + %d,
giveup_count = giveup_count + %d,
total_attempts_to_correct = total_attempts_to_correct + %d",
$tutorial_id,
$question_id,
$attempts,
$correct ? 1 : 0,
$correct ? 0 : ($gave_up ? 0 : 1),
$gave_up ? 1 : 0,
$correct ? $attempts : 0,
// Duplicate key update values:
$attempts,
$correct ? 1 : 0,
$correct ? 0 : ($gave_up ? 0 : 1),
$gave_up ? 1 : 0,
$correct ? $attempts : 0
));
}
}
/**
* Get tutorial statistics for dashboard
* Returns pre-calculated metrics ready for display
*/
public function get_tutorial_stats($tutorial_id) {
$stats = $this->wpdb->get_row($this->wpdb->prepare(
"SELECT
view_count,
completion_count,
CASE WHEN view_count > 0
THEN ROUND((completion_count / view_count) * 100, 1)
ELSE 0 END as completion_rate,
total_quiz_attempts,
total_correct_answers,
CASE WHEN total_quiz_attempts > 0
THEN ROUND((total_correct_answers / total_quiz_attempts) * 100, 1)
ELSE 0 END as avg_score,
total_giveups,
CASE WHEN session_count > 0
THEN ROUND(total_time_seconds / session_count / 60, 1)
ELSE 0 END as avg_time_minutes,
updated_at
FROM {$this->wpdb->prefix}gots_tutorial_stats
WHERE tutorial_id = %d",
$tutorial_id
));
return $stats;
}
/**
* Get question difficulty rankings for a tutorial
* Helps librarians identify problematic questions
*/
public function get_question_stats($tutorial_id) {
return $this->wpdb->get_results($this->wpdb->prepare(
"SELECT
question_id,
question_text,
attempt_count,
correct_count,
giveup_count,
CASE WHEN attempt_count > 0
THEN ROUND((correct_count / attempt_count) * 100, 1)
ELSE 0 END as success_rate,
CASE WHEN correct_count > 0
THEN ROUND(total_attempts_to_correct / correct_count, 1)
ELSE 0 END as avg_attempts_to_correct
FROM {$this->wpdb->prefix}gots_question_stats
WHERE tutorial_id = %d
ORDER BY success_rate ASC", -- Most difficult first
$tutorial_id
));
}
}
REST API Endpoint for receiving session data:
<?php
// File: includes/class-gots-analytics-api.php
add_action('rest_api_init', function() {
register_rest_route('gots/v1', '/analytics', array(
'methods' => 'POST',
'callback' => 'gots_process_analytics',
'permission_callback' => '__return_true', // Public endpoint (anonymous users)
));
});
function gots_process_analytics(WP_REST_Request $request) {
$data = $request->get_json_params();
// Validate required fields
if (empty($data['tutorial_id'])) {
return new WP_Error('missing_tutorial', 'Tutorial ID required', array('status' => 400));
}
// Sanitize and process
$analytics = new GOTS_Analytics();
$analytics->process_session($data);
// Return minimal response (sendBeacon doesn't wait for response anyway)
return array('status' => 'ok');
}
File: assets/js/gots-analytics.js
/**
* Guide on the Side - Client-Side Analytics
*
* Tracks user progress within a single session.
* Data is stored in memory and localStorage (for resume feature).
* NO data is sent to external services.
* NO cookies are set.
* NO fingerprinting is performed.
*/
class GOTSAnalytics {
constructor(tutorialId, tutorialSlug) {
this.tutorialId = tutorialId;
this.tutorialSlug = tutorialSlug;
this.storageKey = `gots_progress_${tutorialSlug}`;
// Session data (memory only until page unload)
this.session = {
startTime: Date.now(),
currentSlide: 0,
slideTimestamps: {},
questions: {},
completed: false
};
// Load any saved progress from localStorage
this.loadProgress();
// Record view on first load
this.recordView();
// Set up page unload handler
this.setupUnloadHandler();
}
/**
* Record tutorial view (called once per page load)
*/
recordView() {
// Simple fetch to increment view counter
fetch('/wp-json/gots/v1/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tutorial_id: this.tutorialId,
action: 'view'
}),
keepalive: true
}).catch(() => {}); // Silently fail - analytics should never break the tutorial
}
/**
* Track slide navigation
*/
trackSlideView(slideId) {
const now = Date.now();
// Calculate dwell time on previous slide
if (this.session.currentSlide && this.session.slideTimestamps[this.session.currentSlide]) {
const dwellTime = now - this.session.slideTimestamps[this.session.currentSlide].entered;
this.session.slideTimestamps[this.session.currentSlide].dwellTime = dwellTime;
}
// Record new slide entry
this.session.currentSlide = slideId;
if (!this.session.slideTimestamps[slideId]) {
this.session.slideTimestamps[slideId] = { entered: now, visits: 0 };
}
this.session.slideTimestamps[slideId].entered = now;
this.session.slideTimestamps[slideId].visits++;
// Save progress for resume feature
this.saveProgress();
}
/**
* Track quiz attempt
*/
trackQuizAttempt(questionId, isCorrect, attemptNumber) {
if (!this.session.questions[questionId]) {
this.session.questions[questionId] = {
attempts: 0,
correct: false,
gaveUp: false,
firstAttemptTime: Date.now()
};
}
this.session.questions[questionId].attempts = attemptNumber;
if (isCorrect) {
this.session.questions[questionId].correct = true;
this.session.questions[questionId].correctTime = Date.now();
}
this.saveProgress();
}
/**
* Track when user gives up on a question
*/
trackGiveUp(questionId) {
if (this.session.questions[questionId]) {
this.session.questions[questionId].gaveUp = true;
}
this.saveProgress();
}
/**
* Mark tutorial as completed
*/
markCompleted() {
this.session.completed = true;
this.session.completedTime = Date.now();
this.saveProgress();
}
/**
* Save progress to localStorage (for resume feature)
* Data never leaves the user's browser until session end
*/
saveProgress() {
const progressData = {
currentSlide: this.session.currentSlide,
questions: this.session.questions,
completed: this.session.completed,
lastUpdated: Date.now()
};
try {
localStorage.setItem(this.storageKey, JSON.stringify(progressData));
} catch (e) {
// localStorage might be full or disabled - continue without saving
console.warn('GOTS: Could not save progress to localStorage');
}
}
/**
* Load saved progress from localStorage
*/
loadProgress() {
try {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
const data = JSON.parse(saved);
// Only restore if less than 24 hours old
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (data.lastUpdated && (Date.now() - data.lastUpdated) < maxAge) {
this.session.currentSlide = data.currentSlide || 0;
this.session.questions = data.questions || {};
this.session.completed = data.completed || false;
return true; // Progress was restored
}
}
} catch (e) {
// Invalid data in localStorage - ignore
}
return false; // No progress restored
}
/**
* Check if user has saved progress (for showing "Resume" prompt)
*/
hasSavedProgress() {
try {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
const data = JSON.parse(saved);
const maxAge = 24 * 60 * 60 * 1000;
return data.lastUpdated && (Date.now() - data.lastUpdated) < maxAge;
}
} catch (e) {}
return false;
}
/**
* Get the slide to resume from
*/
getResumeSlide() {
return this.session.currentSlide || 0;
}
/**
* Clear saved progress (user can do this anytime)
*/
clearProgress() {
try {
localStorage.removeItem(this.storageKey);
} catch (e) {}
// Reset session
this.session = {
startTime: Date.now(),
currentSlide: 0,
slideTimestamps: {},
questions: {},
completed: false
};
}
/**
* Set up handler to send aggregate data when user leaves
*/
setupUnloadHandler() {
const sendAnalytics = () => {
const totalTime = Math.round((Date.now() - this.session.startTime) / 1000);
// Prepare question data for server
const questionsArray = Object.entries(this.session.questions).map(([id, data]) => ({
id: id,
attempts: data.attempts,
correct: data.correct,
gave_up: data.gaveUp
}));
const payload = {
tutorial_id: this.tutorialId,
action: 'session_end',
completed: this.session.completed,
total_time_seconds: totalTime,
quiz_attempts: questionsArray.reduce((sum, q) => sum + q.attempts, 0),
quiz_correct: questionsArray.filter(q => q.correct).length,
giveups: questionsArray.filter(q => q.gave_up).length,
questions: questionsArray
};
// Use sendBeacon for reliable delivery on page unload
navigator.sendBeacon(
'/wp-json/gots/v1/analytics',
JSON.stringify(payload)
);
};
// Send on page hide (works better than unload on mobile)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
sendAnalytics();
}
});
// Fallback for desktop browsers
window.addEventListener('pagehide', sendAnalytics);
}
}
// Initialize when tutorial loads
document.addEventListener('DOMContentLoaded', () => {
if (typeof GOTS_TUTORIAL_ID !== 'undefined') {
window.gotsAnalytics = new GOTSAnalytics(
GOTS_TUTORIAL_ID,
GOTS_TUTORIAL_SLUG
);
// Check for saved progress and prompt user
if (window.gotsAnalytics.hasSavedProgress()) {
const resumeSlide = window.gotsAnalytics.getResumeSlide();
if (resumeSlide > 0) {
showResumePrompt(resumeSlide);
}
}
}
});
File:
assets/js/gots-resume-prompt.js
/**
* Show a prompt to resume from saved progress
* User can choose to resume or start fresh
*/
function showResumePrompt(savedSlide) {
const modal = document.createElement('div');
modal.className = 'gots-resume-modal';
modal.innerHTML = `
<div class="gots-resume-content">
<h3>Welcome Back!</h3>
<p>You have saved progress on this tutorial (Slide ${savedSlide}).</p>
<p>Would you like to continue where you left off?</p>
<div class="gots-resume-buttons">
<button class="gots-btn gots-btn-primary" id="gots-resume-yes">
Resume Tutorial
</button>
<button class="gots-btn gots-btn-secondary" id="gots-resume-no">
Start Over
</button>
</div>
<p class="gots-resume-note">
<small>Your progress is stored only on this device and can be cleared anytime.</small>
</p>
</div>
`;
document.body.appendChild(modal);
document.getElementById('gots-resume-yes').addEventListener('click', () => {
navigateToSlide(savedSlide);
modal.remove();
});
document.getElementById('gots-resume-no').addEventListener('click', () => {
window.gotsAnalytics.clearProgress();
modal.remove();
});
}
CSS for resume prompt:
/* File: assets/css/gots-resume.css */
.gots-resume-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.gots-resume-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 400px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.gots-resume-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
.gots-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.gots-btn-primary {
background: #8C2004; /* UPEI red */
color: white;
}
.gots-btn-secondary {
background: #e0e0e0;
color: #333;
}
.gots-resume-note {
margin-top: 1rem;
color: #666;
}
File:
admin/class-gots-analytics-dashboard.php
<?php
/**
* Analytics Dashboard for Librarians
*
* Simple, at-a-glance view of tutorial performance.
* No complex configurations needed.
*/
class GOTS_Analytics_Dashboard {
public function __construct() {
add_action('admin_menu', array($this, 'add_dashboard_menu'));
}
public function add_dashboard_menu() {
add_submenu_page(
'edit.php?post_type=gots_tutorial',
'Tutorial Analytics',
'Analytics',
'edit_posts', // Librarians can view
'gots-analytics',
array($this, 'render_dashboard')
);
}
public function render_dashboard() {
$analytics = new GOTS_Analytics();
$tutorial_id = isset($_GET['tutorial_id']) ? absint($_GET['tutorial_id']) : 0;
?>
<div class="wrap">
<h1>Tutorial Analytics</h1>
<?php if ($tutorial_id): ?>
<?php $this->render_tutorial_stats($analytics, $tutorial_id); ?>
<?php else: ?>
<?php $this->render_overview($analytics); ?>
<?php endif; ?>
</div>
<?php
}
private function render_overview($analytics) {
global $wpdb;
// Get all tutorials with their stats
$tutorials = $wpdb->get_results("
SELECT
p.ID,
p.post_title,
COALESCE(s.view_count, 0) as views,
COALESCE(s.completion_count, 0) as completions,
CASE WHEN s.view_count > 0
THEN ROUND((s.completion_count / s.view_count) * 100, 1)
ELSE 0 END as completion_rate
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->prefix}gots_tutorial_stats s ON p.ID = s.tutorial_id
WHERE p.post_type = 'gots_tutorial' AND p.post_status = 'publish'
ORDER BY views DESC
");
?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th>Tutorial</th>
<th>Views</th>
<th>Completions</th>
<th>Completion Rate</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($tutorials as $tutorial): ?>
<tr>
<td>
<strong><?php echo esc_html($tutorial->post_title); ?></strong>
</td>
<td><?php echo number_format($tutorial->views); ?></td>
<td><?php echo number_format($tutorial->completions); ?></td>
<td>
<span class="gots-completion-badge <?php echo $tutorial->completion_rate >= 70 ? 'good' : ($tutorial->completion_rate >= 40 ? 'moderate' : 'low'); ?>">
<?php echo $tutorial->completion_rate; ?>%
</span>
</td>
<td>
<a href="?post_type=gots_tutorial&page=gots-analytics&tutorial_id=<?php echo $tutorial->ID; ?>">
View Details
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<style>
.gots-completion-badge {
padding: 4px 8px;
border-radius: 4px;
font-weight: bold;
}
.gots-completion-badge.good { background: #d4edda; color: #155724; }
.gots-completion-badge.moderate { background: #fff3cd; color: #856404; }
.gots-completion-badge.low { background: #f8d7da; color: #721c24; }
</style>
<?php
}
private function render_tutorial_stats($analytics, $tutorial_id) {
$stats = $analytics->get_tutorial_stats($tutorial_id);
$questions = $analytics->get_question_stats($tutorial_id);
$tutorial = get_post($tutorial_id);
?>
<p><a href="?post_type=gots_tutorial&page=gots-analytics">← Back to Overview</a></p>
<h2><?php echo esc_html($tutorial->post_title); ?></h2>
<!-- Key Metrics Cards -->
<div class="gots-stats-cards">
<div class="gots-stat-card">
<span class="gots-stat-number"><?php echo number_format($stats->view_count ?? 0); ?></span>
<span class="gots-stat-label">Total Views</span>
</div>
<div class="gots-stat-card">
<span class="gots-stat-number"><?php echo ($stats->completion_rate ?? 0); ?>%</span>
<span class="gots-stat-label">Completion Rate</span>
</div>
<div class="gots-stat-card">
<span class="gots-stat-number"><?php echo ($stats->avg_score ?? 0); ?>%</span>
<span class="gots-stat-label">Avg Quiz Score</span>
</div>
<div class="gots-stat-card">
<span class="gots-stat-number"><?php echo ($stats->avg_time_minutes ?? 0); ?> min</span>
<span class="gots-stat-label">Avg Time</span>
</div>
</div>
<!-- Question Performance Table -->
<?php if (!empty($questions)): ?>
<h3>Question Performance</h3>
<p class="description">Questions sorted by difficulty (lowest success rate first). Use this to identify questions that need revision.</p>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th>Question</th>
<th>Attempts</th>
<th>Success Rate</th>
<th>Avg Attempts to Correct</th>
<th>Give-ups</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($questions as $q): ?>
<tr>
<td><?php echo esc_html($q->question_id); ?></td>
<td><?php echo number_format($q->attempt_count); ?></td>
<td><?php echo $q->success_rate; ?>%</td>
<td><?php echo $q->avg_attempts_to_correct; ?></td>
<td><?php echo number_format($q->giveup_count); ?></td>
<td>
<?php if ($q->success_rate < 40): ?>
<span class="gots-status-badge difficult">⚠️ Difficult</span>
<?php elseif ($q->success_rate < 70): ?>
<span class="gots-status-badge moderate">Moderate</span>
<?php else: ?>
<span class="gots-status-badge easy">✓ Good</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<!-- Export Button -->
<p style="margin-top: 20px;">
<a href="<?php echo admin_url('admin-ajax.php?action=gots_export_stats&tutorial_id=' . $tutorial_id); ?>"
class="button">
Export to CSV
</a>
</p>
<style>
.gots-stats-cards {
display: flex;
gap: 20px;
margin: 20px 0;
}
.gots-stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
text-align: center;
min-width: 150px;
}
.gots-stat-number {
display: block;
font-size: 2rem;
font-weight: bold;
color: #8C2004;
}
.gots-stat-label {
color: #666;
font-size: 0.9rem;
}
.gots-status-badge {
padding: 2px 8px;
border-radius: 3px;
font-size: 0.85rem;
}
.gots-status-badge.difficult { background: #f8d7da; color: #721c24; }
.gots-status-badge.moderate { background: #fff3cd; color: #856404; }
.gots-status-badge.easy { background: #d4edda; color: #155724; }
</style>
<?php
}
}
// Initialize
new GOTS_Analytics_Dashboard();
| XDO NOT | √DO INSTEAD | Why |
|---|---|---|
| Store IP addresses | Use aggregate counters only | IP = PII under GDPR/PIPEDA |
| Use browser fingerprinting | Accept no unique user tracking | Fingerprinting is invasive |
| Set tracking cookies | Use localStorage for user features only | Cookies require consent |
| Track cross-session behavior | Session dies on browser close | Respects user privacy |
| Log user agent strings | Capture device type only (mobile/desktop) | UA = potential fingerprint |
| Store individual responses | Store aggregates only | Individual data = PII risk |
| Use Google Analytics | Use Koko Analytics | GA sends data to Google |
| Track right-side iframe content | Only track left panel | We don’t control embedded content |
| Require login for students | Allow anonymous access | Client requirement |
| Send data to external services | Process everything server-side | Data stays at UPEI |
Code examples of what NOT to do:
// XBAD: Storing identifiable information
const userData = {
ip: await fetch('https://api.ipify.org').then(r => r.text()), // NO!
userAgent: navigator.userAgent, // NO!
screenSize: `${screen.width}x${screen.height}`, // Fingerprinting - NO!
cookies: document.cookie // NO!
};
// XBAD: Persistent tracking across sessions
localStorage.setItem('user_id', generateUUID()); // Creates persistent identifier - NO!
// XBAD: Sending to external analytics
gtag('event', 'quiz_completed', {...}); // Sends to Google - NO!
// √GOOD: Anonymous aggregate tracking
const sessionData = {
tutorial_id: 123, // Which tutorial, not which user
completed: true,
quiz_attempts: 5,
quiz_correct: 4
// No user identifier anywhere
};
┌─────────────────────────────────────────────────────────────────────────┐
│ DATA FLOW DIAGRAM │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ STUDENT BROWSER UPEI SERVER │
│ ─────────────── ─────────── │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Tutorial Loads │ ──── view +1 ────► │ tutorial_stats │ │
│ └─────────────────┘ │ (aggregate only)│ │
│ │ └─────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ localStorage │ ◄── User's progress (stays in browser) │
│ │ - current slide │ │
│ │ - quiz answers │ ◄── User can clear anytime │
│ │ - timestamps │ │
│ └─────────────────┘ │
│ │ │
│ │ (on page close) │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Aggregate Only │ ── sendBeacon() ─► │ question_stats │ │
│ │ - total time │ │ (aggregate only)│ │
│ │ - # attempts │ └─────────────────┘ │
│ │ - # correct │ │ │
│ │ - completed? │ ▼ │
│ └─────────────────┘ ┌─────────────────┐ │
│ │ Librarian │ │
│ NO user ID sent │ Dashboard │ │
│ NO IP logged │ - completion % │ │
│ NO cross-session link │ - question diff │ │
│ │ - trends │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Phase 1: Core Analytics (MVP) - [ ] Create database
tables (schema above) - [ ] Implement GOTS_Analytics PHP
class - [ ] Create REST API endpoint for receiving data - [ ] Implement
GOTSAnalytics JavaScript class - [ ] Add view counter on
tutorial load - [ ] Add session end handler with sendBeacon
Phase 2: Question Tracking - [ ] Integrate quiz attempt tracking with quiz UI - [ ] Track correct/incorrect/give-up states - [ ] Store question-level aggregates
Phase 3: Resume Feature - [ ] Implement localStorage save/load - [ ] Create resume prompt UI - [ ] Add “Clear Progress” button for users - [ ] Set 24-hour expiry on saved progress
Phase 4: Dashboard - [ ] Create admin menu page - [ ] Build overview table with all tutorials - [ ] Build detail view with question breakdown - [ ] Add CSV export functionality - [ ] Style with UPEI colors
This section verifies that our proposed analytics methodology complies with UPEI’s privacy policy, Canadian privacy law (PIPEDA), and international standards (GDPR).
Policy Reference: UPEI Policy No. govbrdgnl0017 - “Access to Information and Protection of Personal Information and Privacy” (Effective May 1, 2017)
The UPEI policy defines “personal information” as information about an identifiable individual, including:
| Category | Examples | Do We Collect? |
|---|---|---|
| Direct identifiers | Name, student ID, email, phone | XNO |
| Home contact info | Home address, home phone | XNO |
| Personal characteristics | Age, sex, race, marital status | XNO |
| Background info | Educational, employment history | XNO |
| Financial information | Income, banking details | XNO |
| Medical information | Health records | XNO |
| Opinions/evaluations | Comments, assessments | XNO (quiz answers are not stored individually) |
| IP addresses | Network identifiers | XNO |
√Compliance Status: FULLY COMPLIANT
Our methodology collects zero personal information. We only collect aggregate statistics (totals and averages) that cannot be linked to any individual.
The UPEI policy states that PIPEDA only applies when the University collects personal information in the course of “commercial activity.”
Key definitions from the policy:
“Commercial activity” means any particular transaction, act, or conduct that is of a “commercial character,” including selling, bartering or leasing of donor, membership, or other fundraising lists. (Section 22)
An activity has a “commercial character” if it: - involves an exchange of goods or services for valuable consideration, - is for the purpose of creating profit, generating revenue, or producing positive cash flow, and - is not principally educational in nature. (Section 23)
Assessment for Guide on the Side:
| Criterion | Our Project | Result |
|---|---|---|
| Exchange for valuable consideration? | No - tutorials are free to students | XNot commercial |
| Purpose of creating profit? | No - educational service | XNot commercial |
| Principally educational? | Yes - teaching library research skills | √Educational |
√PIPEDA Determination: LIKELY DOES NOT APPLY
The Guide on the Side tutorial system is “principally educational in nature” and does not involve commercial activity. However, even if PIPEDA did apply, we would still be compliant because we do not collect personal information at all.
Section 17 - Personal information may be collected for: - Institutional planning and statistics √(this is what analytics serves) - Delivering services at the University √(improving tutorials)
Section 18 - Personal information should not be disclosed unless: - The individual consented, OR - Disclosure is permitted by law, OR - Information is public
Section 26 - Collection only for purposes a “reasonable person would consider appropriate”
Our compliance: - We collect no personal information, so disclosure rules don’t apply - Aggregate statistics (e.g., “500 people viewed this tutorial”) are not personal information - A reasonable person would expect an educational platform to track usage metrics for improvement purposes - Students are not required to login, consent, or provide any data
Even though PIPEDA likely doesn’t apply to our educational activity, we’ve designed the system to meet PIPEDA’s standards as a best practice.
PIPEDA’s 10 Fair Information Principles:
| Principle | Requirement | Our Compliance |
|---|---|---|
| 1. Accountability | Organization responsible for data | √UPEI IT manages all data |
| 2. Identifying Purposes | State why data is collected | √Analytics for tutorial improvement |
| 3. Consent | Obtain meaningful consent | √N/A - no personal info collected |
| 4. Limiting Collection | Collect only what’s needed | √Aggregates only, no PII |
| 5. Limiting Use | Use only for stated purpose | √Only for librarian analytics |
| 6. Accuracy | Keep information accurate | √Automated counters are accurate |
| 7. Safeguards | Protect information | √Data stays on UPEI servers |
| 8. Openness | Be transparent about practices | √Can add privacy notice to tutorials |
| 9. Individual Access | Allow access to own data | √N/A - no individual data stored |
| 10. Challenging Compliance | Handle complaints | √UPEI has Office of UPEIIP |
√PIPEDA Compliance: EXCEEDS REQUIREMENTS
By not collecting personal information, we avoid the majority of PIPEDA obligations entirely.
Although GDPR (EU’s General Data Protection Regulation) doesn’t directly apply to UPEI, some students or visitors may be EU residents. Designing for GDPR compliance is best practice.
GDPR Personal Data Definition: > Any information relating to an identified or identifiable natural person (‘data subject’)
Do we process personal data under GDPR?
| Data Type | GDPR Status | Our Approach |
|---|---|---|
| IP addresses | Personal data | XWe do NOT log IP addresses |
| Cookies | Requires consent if tracking | XWe use NO cookies (or optional) |
| Device fingerprints | Personal data | XWe do NOT fingerprint |
| User accounts | Personal data | XStudents don’t login |
| Aggregate statistics | Not personal data | √This is all we collect |
GDPR Lawful Basis (if we did collect personal data):
Even if we collected minimal data, we could rely on: - Legitimate Interest (Article 6(1)(f)) - improving educational services - Public Task (Article 6(1)(e)) - university’s educational mission
But since we collect no personal data, GDPR compliance is straightforward:
√GDPR Compliance: FULLY COMPLIANT
Our cookieless, anonymous approach requires: - No consent banner - No data subject access requests (no individual data exists) - No data processing agreements - No cross-border transfer concerns (data stays at UPEI)
| Requirement | UPEI Policy | PIPEDA | GDPR | Our Approach |
|---|---|---|---|---|
| Define personal info | √Section 24 | √Schedule 1 | √Article 4 | We collect none |
| Consent for collection | Required for PII | Required for PII | Required for PII | N/A - no PII |
| Purpose limitation | Sections 17, 26 | Principle 2 | Article 5(1)(b) | √Only for analytics |
| Data minimization | Implied | Principle 4 | Article 5(1)(c) | √Aggregates only |
| Storage limitation | Not specified | Principle 5 | Article 5(1)(e) | √Can set retention |
| Security | Section 18 | Principle 7 | Article 32 | √UPEI servers only |
| Transparency | Section 18 | Principle 8 | Articles 12-14 | √Can add notice |
| Data subject rights | Section 19 | Principle 9 | Articles 15-22 | N/A - no individual data |
Even with our privacy-first approach, there are edge cases to consider:
| Concern | Risk Level | Mitigation |
|---|---|---|
| localStorage stores quiz answers | Low | Data never sent to server; user can clear anytime; 24hr expiry |
| Session timing could reveal patterns | Very Low | Only aggregate time stored; no individual timelines |
| Question-level stats could identify individuals | Very Low | Need 100s of responses before stats are meaningful |
| Future feature creep | Medium | Document privacy boundaries clearly; require review for changes |
| Third-party embed tracking | Outside our control | We explicitly don’t track right-side panel |
Recommended Privacy Notice (optional but good practice):
<!-- Add to tutorial footer -->
<p class="gots-privacy-notice">
<small>
This tutorial collects anonymous usage statistics (page views, completion rates)
to help improve our content. No personal information is collected or stored.
Your progress is saved locally on your device and can be cleared anytime.
</small>
</p>
Before implementation, confirm the following with Chris Vessey (ITSS):
Status: √APPROVED FOR DEVELOPMENT
We reached out to Chris Vessey (ITSS) to confirm our analytics approach aligns with UPEI’s policies. His response confirms we can proceed:
“As this is an educational development project, and not a deployment, I don’t think we need to involve our Privacy Office at this time. Depending on how it evolves, you may need to in the future.”
— Chris Vessey, ITSS
Key Takeaways:
| Item | Status | Notes |
|---|---|---|
| Privacy Office Review | Not required (for now) | Educational development project exemption |
| Future Consideration | May be needed | If project moves to production deployment |
| VM Hosting | Available | Contact Chris with team number to assign |
| Self-hosting | Acceptable | Can use own hosting if preferred |
What This Means for Implementation:
Action Items:
Note for Future Deployment:
If Guide on the Side moves beyond educational development into production use by the library, the team (or UPEI Library staff) should: 1. Re-engage with Chris Vessey for production hosting requirements 2. Potentially involve the Office of UPEIIP for formal privacy review 3. Update this documentation with any additional compliance requirements
| Framework | Status | Notes |
|---|---|---|
| UPEI Policy govbrdgnl0017 | √Compliant | No personal information collected |
| PIPEDA | √Likely exempt + compliant | Educational activity; no PII regardless |
| GDPR | √Compliant | Cookieless, no PII, no consent needed |
| Client Requirements | √Compliant | Anonymous access, no tracking |
Key Compliance Points: - We collect zero personal information - All data is aggregate only (totals and averages) - Data never leaves UPEI servers - Students don’t need to login or consent - The system is private by design, not by afterthought
Important Note: As highlighted by Professor LeBlanc, achieving only a minimal MVP results in a base mark. To demonstrate competency and exceed expectations, we must implement a complete, functional analytics system — not just rely on platform defaults.
This section provides a realistic implementation plan that delivers meaningful analytics librarians can actually use.
Looking at the Feature List (6.1-6.4) and Use Case documentation, the analytics system must deliver:
| Acceptance Criteria | Feature ID | Required for MVP? |
|---|---|---|
| Deliverable detailing anonymous metrics collection process | 6.1 | √This document |
| System collects usage data (views + completions) | 6.2 | √Yes |
| Documentation for analytics dashboard page | 6.3 | √Yes |
| Functioning implementation of analytics dashboard | 6.4 | √Yes |
| Views, completion rate, average quiz score, slide dwell time | Use Case | √Yes |
| Filter by date range | Use Case | √Yes |
| Export anonymized reports (CSV/PDF) | Use Case | √Yes |
Conclusion: MVP = Full custom analytics implementation, not just Koko Analytics.
The MVP must include a working analytics system that librarians can use to measure tutorial effectiveness.
| Component | What It Does | Effort | Sprint |
|---|---|---|---|
| Database Schema | 3 tables for storing aggregates | 2 hrs | Sprint 4 |
| View Counter | Increment on tutorial load | 2 hrs | Sprint 4 |
| Completion Tracking | Record when final slide reached | 3 hrs | Sprint 4 |
| Quiz Aggregate Stats | Track attempts, correct, give-ups | 4 hrs | Sprint 4 |
| Session Timing | Calculate time spent in tutorial | 2 hrs | Sprint 4 |
| Client-Side JS | GOTSAnalytics class + sendBeacon | 4 hrs | Sprint 4 |
| REST API Endpoint | Receive and process session data | 3 hrs | Sprint 4 |
| Resume Feature | localStorage save/restore | 4 hrs | Sprint 4 |
| Dashboard Overview | Table of all tutorials with stats | 4 hrs | Sprint 5 |
| Dashboard Detail View | Per-tutorial metrics + questions | 6 hrs | Sprint 5 |
| Date Range Filter | Filter stats by date | 3 hrs | Sprint 5 |
| CSV Export | Download stats as spreadsheet | 3 hrs | Sprint 5 |
| Koko Analytics Integration | Configure for page views | 2 hrs | Sprint 4 |
Total MVP Effort: ~42 hours (fits within 2 sprints with buffer)
Sprint 4: Data Collection Layer
Week 1:
├── Create database tables (wp_gots_tutorial_stats, wp_gots_question_stats, wp_gots_daily_stats)
├── Implement GOTS_Analytics PHP class
│ ├── record_view()
│ ├── process_session()
│ └── get_tutorial_stats()
├── Create REST API endpoint /wp-json/gots/v1/analytics
└── Install and configure Koko Analytics plugin
Week 2:
├── Implement GOTSAnalytics JavaScript class
│ ├── trackSlideView()
│ ├── trackQuizAttempt()
│ ├── trackGiveUp()
│ └── sendBeacon on unload
├── Implement localStorage resume feature
│ ├── saveProgress()
│ ├── loadProgress()
│ └── Resume prompt modal
└── Integration testing with tutorial UI
Sprint 5: Dashboard & Reporting
Week 1:
├── Create GOTS_Analytics_Dashboard class
├── Add admin menu page (Tutorial → Analytics)
├── Build overview table
│ ├── All tutorials list
│ ├── View count, completion count, completion rate
│ └── Color-coded performance badges
└── Build detail view
├── Key metrics cards (views, completion %, avg score, avg time)
├── Question difficulty table
└── Trend indicator
Week 2:
├── Implement date range filter
│ ├── Date picker UI
│ ├── Filter query logic
│ └── Update stats on filter change
├── Implement CSV export
│ ├── Export button
│ ├── Generate CSV with stats
│ └── Trigger download
└── Final testing and documentation
Data Collection (Sprint 4): - [ ] Database tables created and migrated - [ ] View counter working (verify with test tutorial) - [ ] Completion tracking working - [ ] Quiz attempts being recorded - [ ] Session timing being captured - [ ] localStorage resume feature functional - [ ] Koko Analytics showing page views
Dashboard (Sprint 5): - [ ] Analytics menu visible in admin - [ ] Overview table shows all tutorials - [ ] Clicking tutorial shows detail view - [ ] Date filter changes displayed data - [ ] CSV export downloads correctly - [ ] Stats update in real-time as users complete tutorials
These features go beyond the acceptance criteria and demonstrate polish:
| Enhancement | Description | Effort | Value |
|---|---|---|---|
| Slide Dwell Time | Track time per slide, identify where users get stuck | 6 hrs | High |
| Device Type Tracking | Mobile vs desktop breakdown | 2 hrs | Medium |
| Trend Charts | Visual charts showing usage over time | 8 hrs | High |
| Question-Level Detail | Click into specific question analytics | 4 hrs | Medium |
| PDF Export | Generate PDF reports for stakeholders | 6 hrs | Medium |
| Dashboard Widgets | Summary widget on WP dashboard | 4 hrs | Low |
| Comparison View | Compare two tutorials side-by-side | 6 hrs | Medium |
| Email Reports | Scheduled weekly stats email to librarians | 8 hrs | Low |
| A/B Insights | Compare different tutorial versions | 10 hrs | Low |
Recommended Post-MVP Priority:
┌─────────────────────────────────────────────────────────────────────────────┐
│ GUIDE ON THE SIDE ANALYTICS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ STUDENT BROWSER │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ GOTSAnalytics.js │ │
│ │ ├── trackSlideView(slideId) │ │
│ │ ├── trackQuizAttempt(questionId, correct, attempt) │ │
│ │ ├── trackGiveUp(questionId) │ │
│ │ ├── markCompleted() │ │
│ │ └── sendBeacon() on page unload ─────────────────────┐ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ localStorage │ POST │
│ ▼ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
│ │ Resume Progress │ │ /wp-json/gots/v1/analytics │ │
│ │ (stays in browser) │ │ REST API Endpoint │ │
│ └──────────────────────┘ └──────────────────────────────┘ │
│ │ │
├────────────────────────────────────────────────────────│────────────────────┤
│ UPEI SERVER │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ GOTS_Analytics.php │ │
│ │ ├── record_view($tutorial_id) │ │
│ │ ├── process_session($data) │ │
│ │ ├── get_tutorial_stats($tutorial_id) │ │
│ │ └── get_question_stats($tutorial_id) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ WordPress Database │ │
│ │ ├── wp_gots_tutorial_stats (aggregates per tutorial) │ │
│ │ ├── wp_gots_question_stats (aggregates per question) │ │
│ │ └── wp_gots_daily_stats (daily totals for trends) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ GOTS_Analytics_Dashboard.php (Admin UI) │ │
│ │ ├── Overview: All tutorials table with key metrics │ │
│ │ ├── Detail: Per-tutorial deep dive │ │
│ │ ├── Filters: Date range selector │ │
│ │ └── Export: CSV download │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ LIBRARIAN DASHBOARD │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Views │ │Complete%│ │Avg Score│ │Avg Time │ │ │
│ │ │ 1,234 │ │ 72.3% │ │ 85.1% │ │ 8.5 min │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ │ │ │
│ │ Question Performance [Export CSV] │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Q1: Database Search │ 92% │ ████████████░░ │ Good │ │ │
│ │ │ Q2: Boolean Operators│ 45% │ ██████░░░░░░░░ │ Hard │ │ │
│ │ │ Q3: Citation Format │ 78% │ ██████████░░░░ │ OK │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
How we’ll know the analytics system is working:
| Metric | Target | How to Verify |
|---|---|---|
| View counting accuracy | 100% | Compare with Koko Analytics |
| Completion tracking | 100% | Manual testing with test tutorials |
| Quiz stats recording | All attempts logged | Check database after quiz completion |
| Dashboard load time | < 2 seconds | Browser dev tools |
| CSV export | Contains all visible data | Download and verify contents |
| Resume feature | Works across browser sessions | Close and reopen browser |
| Privacy compliance | Zero PII in database | Query database, verify no identifiers |
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| sendBeacon not supported | Low | Data loss on old browsers | Fallback to fetch with keepalive |
| localStorage disabled | Low | Resume feature fails | Graceful degradation, no error shown |
| High traffic spikes | Medium | DB write bottleneck | Batch writes, use MySQL ON DUPLICATE KEY |
| Dashboard slow with many tutorials | Low | Bad UX | Pagination, limit initial query |
| Time zone issues with date filter | Medium | Wrong date ranges | Store all timestamps in UTC |
Files to create/modify:
guide-on-the-side/
├── includes/
│ ├── class-gots-analytics.php # Core analytics logic
│ ├── class-gots-analytics-api.php # REST endpoint
│ └── class-gots-db-setup.php # Database migration
├── admin/
│ ├── class-gots-analytics-dashboard.php # Admin UI
│ └── css/
│ └── gots-admin-analytics.css # Dashboard styles
├── assets/
│ ├── js/
│ │ ├── gots-analytics.js # Client-side tracking
│ │ └── gots-resume-prompt.js # Resume modal
│ └── css/
│ └── gots-resume.css # Resume modal styles
└── templates/
└── analytics-privacy-notice.php # Optional footer notice
| Phase | Scope | Deliverable | Grade Impact |
|---|---|---|---|
| Research (This Doc) | Feature 6.1 | Anonymous analytics methodology | Acceptance criteria met |
| MVP (Sprints 4-5) | Features 6.2-6.4 | Full working analytics system | Demonstrates competency |
| Post-MVP (Sprint 6+) | Enhancements | Charts, dwell time, polish | Exceeds expectations |
Bottom Line:
Don’t settle for “just Koko Analytics.” Implement the full custom system described in Section 4. The code is ready — it just needs to be integrated with the tutorial UI and tested.
A complete analytics dashboard that helps librarians improve their tutorials is exactly what differentiates a good project from a mediocre one.
UPEI Policies: - UPEI Policy govbrdgnl0017 - Access to Information and Protection of Personal Information and Privacy
Privacy Regulations: - Personal Information Protection and Electronic Documents Act (PIPEDA), S.C. 2000, c 5 - General Data Protection Regulation (GDPR), EU 2016/679
Technical Documentation: - Koko Analytics: https://www.kokoanalytics.com/ - WordPress REST API: https://developer.wordpress.org/rest-api/ - navigator.sendBeacon(): https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon - localStorage API: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
Platform Documentation: - Pressbooks Network Manager Guide: https://networkmanagerguide.pressbooks.com/ - LibWizard (competitor reference): https://www.springshare.com/libwizard/
| Term | Definition |
|---|---|
| Aggregate data | Combined statistics (totals, averages) that cannot identify individuals |
| PII | Personally Identifiable Information - data that can identify a specific person |
| PIPEDA | Canada’s federal privacy law for commercial activities |
| GDPR | EU’s privacy regulation, considered the gold standard internationally |
| Cookieless tracking | Analytics that doesn’t use browser cookies |
| sendBeacon | Browser API for reliable data transmission on page unload |
| localStorage | Browser storage that persists until explicitly cleared |
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | January 2025 | Qi Xiang Phang | Initial research deliverable |
End of Document