Research: Anonymous Analytics & Data Collection Methods

Guide on the Side — Team 8

Author: Qi Xiang Phang
Date: January 2025
Document Type: Research Deliverable (Feature 6.1)


1. Executive Summary

1.1 Purpose

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.

1.2 Key Constraints

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.

1.3 Metrics We Need to Collect

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.

1.4 Recommendation Summary

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


2. Anonymous Data Collection Methods

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.

2.1 Aggregate Counters (Server-Side)

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


2.2 Session-Based Tracking (Ephemeral)

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


2.3 Event Tracking (Behavioral 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);

2.4 Hashed/Anonymized Identifiers (Optional—Unique Visitor Counting)

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


2.5 Summary: Method Selection Guide

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)


3. Platform-Native Analytics Options

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.


3.1 Pressbooks Built-In Analytics

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


3.2 Koko Analytics (WordPress Plugin)

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)


3.3 WordPress Native Options (Alternatives)

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.


3.4 LibWizard Analytics (Competitor Reference)

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.


3.5 Custom Implementation (HTML/CSS/JS/PHP)

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

3.6 Comparison Table: All Options

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)


3.5 Complete Implementation Code (Copy-Paste Ready)

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.


3.5.1 Plugin File Structure

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

3.5.2 Database Setup (class-gots-analytics-db.php)

<?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();
});

3.5.3 Core Analytics Processor (class-gots-analytics.php)

<?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;
    }
}

3.5.4 REST API Endpoints (class-gots-analytics-api.php)

<?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'));

3.5.5 Client-Side JavaScript (gots-analytics.js)

/**
 * 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();
 *     }
 * });
 */

3.5.6 WordPress Script Enqueue & Localization

<?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'),
    ));
}

3.5.7 Koko Analytics Integration

<?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,
    );
}

3.5.8 CSS Styles (gots-resume.css)

/**
 * 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;
    }
}

4. Implementation Guide: Custom Analytics for Guide on the Side


4.1 Database Schema for Tutorial Analytics

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


4.2 Server-Side Aggregate Counters (PHP)

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');
}

4.3 Client-Side Session Tracking (JavaScript)

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);
            }
        }
    }
});

4.4 Resume Feature UI Implementation

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;
}

4.5 Librarian Analytics Dashboard (Admin UI)

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">&larr; 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();

4.6 What to Avoid (Anti-Patterns)

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
};

4.7 Data Flow Summary

┌─────────────────────────────────────────────────────────────────────────┐
│                         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        │            │
│                                          └─────────────────┘            │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

4.8 Implementation Checklist for Developers

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


5. UPEI Compliance & Privacy Considerations

This section verifies that our proposed analytics methodology complies with UPEI’s privacy policy, Canadian privacy law (PIPEDA), and international standards (GDPR).


5.1 UPEI Privacy Policy Analysis

Policy Reference: UPEI Policy No. govbrdgnl0017 - “Access to Information and Protection of Personal Information and Privacy” (Effective May 1, 2017)

What the Policy Defines as Personal Information (Section 24)

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.


PIPEDA Applicability (Sections 20-27)

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.


Disclosure and Collection Principles (Sections 17-18, 26)

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


5.2 PIPEDA Compliance (Federal Law)

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.


5.3 GDPR Compliance (International Standard)

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)


5.4 Comparison: Our Approach vs. Policy Requirements

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

5.5 Potential Concerns & Mitigations

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>

5.6 Recommendations for UPEI IT Review

Before implementation, confirm the following with Chris Vessey (ITSS):

  1. Server location: Confirm analytics data will be stored on UPEI-controlled servers only
  2. Database access: Confirm who will have access to the analytics tables
  3. Retention policy: Agree on how long aggregate data should be kept (suggest: 5 years, matching tutorial lifecycle)
  4. Backup scope: Confirm analytics tables will be included in regular backups
  5. Koko Analytics: Confirm this plugin is acceptable for UPEI’s WordPress/Pressbooks installation
  6. Privacy notice: Confirm whether a privacy notice should be displayed on tutorials

5.7 Confirmation from ITSS (Chris Vessey)

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:

  1. We can proceed with our proposed analytics approach — No blockers from UPEI IT/Privacy
  2. No UPEIIP coordination required during development — Simplifies our timeline
  3. Document everything — If the project is deployed to production later, we’ll have compliance documentation ready
  4. Server options available — Team can use UPEI-provided VM or existing Docker setup

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


5.8 Compliance Summary

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


7. Appendix

7.1 References

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/

7.2 Glossary

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

7.3 Document History

Version Date Author Changes
1.0 January 2025 Qi Xiang Phang Initial research deliverable

End of Document