IoT Class
  • IoT Class
  • All Modules
  • Progress
  • Login

On This Page

  • Overview Metrics
  • Content Performance
  • Engagement Analysis
  • Content by Category
  • Data Export

Analytics Dashboard

Platform Usage Metrics and Insights

Analytics Dashboard

Real-time platform usage metrics and insights

Loading...
Show code
Plot = require("@observablehq/plot@0.6")

// Initialize state
mutable isLoading = true
mutable hasError = false
mutable errorMessage = ""
mutable timePeriod = 30
mutable lastRefresh = new Date()

// Admin access check
isAdmin = {
  await new Promise(resolve => setTimeout(resolve, 500)); // Wait for auth
  // B8: ask the database (public.is_admin() over user_roles) rather than
  // window.Auth.isUserAdmin(), which is a mutable flag derived from a public list.
  if (typeof window.IoTClassSupabase?.isAdminServerSide === 'function') {
    return await window.IoTClassSupabase.isAdminServerSide();
  }
  return false;
}
Show code
// Time period selector
viewof selectedPeriod = {
  const periods = [
    { value: 7, label: "7 Days" },
    { value: 30, label: "30 Days" },
    { value: 90, label: "90 Days" }
  ];

  const container = html`<div class="time-selector" role="group" aria-label="Analytics period">
    ${periods.map(p => html`
      <button type="button"
        class="${p.value === timePeriod ? 'active' : ''}"
        data-period="${p.value}"
      >${p.label}</button>
    `)}
  </div>`;

  container.querySelectorAll('[data-period]').forEach((button) => {
    button.addEventListener('click', () => {
      mutable timePeriod = Number(button.dataset.period);
    });
  });

  return container;
}
Show code
// Access denied view
accessDenied = html`
<div class="access-denied">
  <h2>Access Denied</h2>
  <p>You must be logged in as an administrator to view this page.</p>
  <p>Please <a href="/hubs/dashboard.html">sign in</a> with an admin account.</p>
</div>
`
Show code
analyticsData = {
  if (!isAdmin) return null;

  mutable isLoading = true;
  mutable hasError = false;

  try {
    await new Promise(resolve => setTimeout(resolve, 100));

    // Get Analytics module
    const Analytics = window.Analytics;
    if (!Analytics) {
      throw new Error("Analytics module not loaded");
    }

    // Fetch all data in parallel
    const [metrics, popular, viewsOverTime, lowEngagement, paths] = await Promise.all([
      Analytics.getEngagementMetrics({ days: timePeriod }),
      Analytics.getPopularContent({ days: timePeriod, limit: 20 }),
      Analytics.getViewsOverTime({ days: timePeriod }),
      Analytics.getLowEngagementContent({ days: timePeriod, limit: 15 }),
      Analytics.getCommonPaths({ days: timePeriod, limit: 10 })
    ]);

    mutable isLoading = false;
    mutable lastRefresh = new Date();

    return {
      metrics,
      popular,
      viewsOverTime,
      lowEngagement,
      paths,
      period: timePeriod
    };
  } catch (error) {
    console.error('[Analytics Dashboard] Error:', error);
    mutable hasError = true;
    mutable errorMessage = error.message;
    mutable isLoading = false;

    // Return mock data for demo
    return getMockData(timePeriod);
  }
}

// Mock data function for when Supabase is not available
function getMockData(days) {
  const baseViews = Math.floor(Math.random() * 500) + 500;
  const viewsPerDay = [];
  const today = new Date();

  for (let i = days; i >= 0; i--) {
    const date = new Date(today);
    date.setDate(date.getDate() - i);
    viewsPerDay.push({
      date: date,
      dateStr: date.toISOString().split('T')[0],
      views: Math.floor(Math.random() * 50) + 20 + (i < 7 ? 15 : 0)
    });
  }

  return {
    metrics: {
      totalViews: baseViews,
      uniqueUsers: Math.floor(baseViews * 0.65),
      uniqueSessions: Math.floor(baseViews * 0.8),
      avgTimeOnPage: 245,
      avgTimeFormatted: "4:05",
      bounceRate: 32.5,
      avgScrollDepth: 68,
      completionRate: 45.2,
      avgPagesPerSession: 3.4
    },
    popular: [
      { title: "IoT Fundamentals", views: 156, url: "/fundamentals/iot-overview.html", part: "Fundamentals" },
      { title: "MQTT Protocol Deep Dive", views: 134, url: "/app-protocols/mqtt-fundamentals.html", part: "Networking" },
      { title: "LoRaWAN Architecture", views: 121, url: "/lorawan/lorawan-overview.html", part: "Networking" },
      { title: "Sensor Calibration", views: 98, url: "/sensors/sensor-calibration-lab.html", part: "Sensing" },
      { title: "Edge Computing", views: 87, url: "/reference-architectures/edge.html", part: "Architectures" },
      { title: "Security Best Practices", views: 82, url: "/security-threats/iot-security-fundamentals.html", part: "Security" },
      { title: "Data Analytics Intro", views: 76, url: "/analytics-ml/analytics-overview.html", part: "Data" },
      { title: "Smart Home Use Cases", views: 71, url: "/applications/iot-use-cases-smart-home.html", part: "Applications" },
      { title: "BLE Fundamentals", views: 65, url: "/bluetooth-ble/bluetooth-fundamentals.html", part: "Networking" },
      { title: "PID Control Systems", views: 58, url: "/reference-architectures/pid-control-theory.html", part: "Sensing" }
    ],
    viewsOverTime: viewsPerDay,
    lowEngagement: [
      { title: "Appendix A", views: 12, bounceRate: 78, avgScrollDepth: 22, avgDuration: 35, engagementScore: 18 },
      { title: "Legacy Protocols", views: 8, bounceRate: 72, avgScrollDepth: 31, avgDuration: 42, engagementScore: 24 },
      { title: "Historical Context", views: 15, bounceRate: 65, avgScrollDepth: 38, avgDuration: 55, engagementScore: 32 }
    ],
    paths: [
      { fromTitle: "Home", toTitle: "IoT Fundamentals", count: 89 },
      { fromTitle: "IoT Fundamentals", toTitle: "MQTT Protocol", count: 67 },
      { fromTitle: "Dashboard", toTitle: "My Progress", count: 54 },
      { fromTitle: "MQTT Protocol", toTitle: "LoRaWAN", count: 43 },
      { fromTitle: "Sensor Calibration", toTitle: "PID Control", count: 38 }
    ],
    period: days
  };
}
Show code
viewContent = {
  if (!isAdmin) {
    return accessDenied;
  }

  if (isLoading) {
    return html`<div class="loading-spinner">Loading analytics data...</div>`;
  }

  if (hasError) {
    return html`
      <div class="admin-notice">
        <span class="admin-notice-icon">Note:</span>
        <span>Unable to connect to database. Showing demo data. Error: ${errorMessage}</span>
      </div>
    `;
  }

  return html``;
}

viewContent

Overview Metrics

Show code
statsCards = {
  if (!analyticsData) return html``;

  const m = analyticsData.metrics;
  const stats = [
    { value: m.totalViews.toLocaleString(), label: "Total Page Views", change: "+12%", positive: true },
    { value: m.uniqueUsers.toLocaleString(), label: "Unique Visitors", change: "+8%", positive: true },
    { value: m.avgTimeFormatted, label: "Avg Time on Page", change: "+0:32", positive: true },
    { value: m.bounceRate + "%", label: "Bounce Rate", change: "-2.1%", positive: true },
    { value: m.avgScrollDepth + "%", label: "Avg Scroll Depth", change: "+5%", positive: true },
    { value: m.completionRate + "%", label: "Content Completion", change: "+3.2%", positive: true },
    { value: m.avgPagesPerSession.toFixed(1), label: "Pages per Session", change: "+0.3", positive: true },
    { value: m.uniqueSessions.toLocaleString(), label: "Total Sessions", change: "+15%", positive: true }
  ];

  return html`
    <div class="stats-grid">
      ${stats.map(s => html`
        <div class="stat-card">
          <div class="stat-value">${s.value}</div>
          <div class="stat-label">${s.label}</div>
          <div class="stat-change ${s.positive ? 'positive' : 'negative'}">
            ${s.positive ? '↑' : '↓'} ${s.change} vs prev ${timePeriod} days
          </div>
        </div>
      `)}
    </div>
  `;
}

statsCards

Content Performance

Top 20 Most Viewed Chapters

Show code
popularChart = {
  if (!analyticsData || !analyticsData.popular.length) {
    return html`<div class="no-data">No page view data available</div>`;
  }

  const data = analyticsData.popular.slice(0, 20);
  const maxViews = Math.max(...data.map(d => d.views));

  return html`
    <div class="chart-section">
      <div class="chart-header">
        <div>
          <h3 class="chart-title">Most Viewed Content</h3>
          <p class="chart-subtitle">Last ${timePeriod} days</p>
        </div>
        <div class="chart-actions">
          <button type="button" onclick="exportChartData('popular')">Export</button>
        </div>
      </div>
      ${Plot.plot({
        marginLeft: 180,
        marginRight: 50,
        width: 500,
        height: 450,
        x: {
          label: "Views",
          grid: true
        },
        y: {
          label: null,
          domain: data.map(d => d.title.length > 25 ? d.title.substring(0, 22) + '...' : d.title)
        },
        marks: [
          Plot.barX(data, {
            x: "views",
            y: d => d.title.length > 25 ? d.title.substring(0, 22) + '...' : d.title,
            fill: "#16A085",
            tip: true,
            title: d => `${d.title}\n${d.views} views\nPart: ${d.part}`
          }),
          Plot.text(data, {
            x: d => d.views + maxViews * 0.02,
            y: d => d.title.length > 25 ? d.title.substring(0, 22) + '...' : d.title,
            text: d => d.views.toLocaleString(),
            textAnchor: "start",
            fill: "#2C3E50",
            fontSize: 11
          })
        ],
        color: {
          scheme: "teals"
        }
      })}
    </div>
  `;
}

popularChart

Views Over Time

Show code
viewsTimeChart = {
  if (!analyticsData || !analyticsData.viewsOverTime.length) {
    return html`<div class="no-data">No time series data available</div>`;
  }

  const data = analyticsData.viewsOverTime;

  return html`
    <div class="chart-section">
      <div class="chart-header">
        <div>
          <h3 class="chart-title">Daily Page Views</h3>
          <p class="chart-subtitle">Trend over last ${timePeriod} days</p>
        </div>
      </div>
      ${Plot.plot({
        width: 500,
        height: 300,
        marginBottom: 40,
        x: {
          type: "time",
          label: "Date",
          tickRotate: -45
        },
        y: {
          label: "Views",
          grid: true
        },
        marks: [
          Plot.areaY(data, {
            x: "date",
            y: "views",
            fill: "#16A085",
            fillOpacity: 0.2
          }),
          Plot.lineY(data, {
            x: "date",
            y: "views",
            stroke: "#16A085",
            strokeWidth: 2
          }),
          Plot.dot(data, {
            x: "date",
            y: "views",
            fill: "#2C3E50",
            r: 3,
            tip: true,
            title: d => `${d.dateStr}: ${d.views} views`
          })
        ]
      })}
    </div>
  `;
}

viewsTimeChart

Engagement Analysis

Lowest Engagement Content

Show code
lowEngagementTable = {
  if (!analyticsData || !analyticsData.lowEngagement.length) {
    return html`<div class="no-data">No engagement data available</div>`;
  }

  const data = analyticsData.lowEngagement;

  const getScoreClass = (score) => {
    if (score < 30) return 'score-low';
    if (score < 60) return 'score-medium';
    return 'score-high';
  };

  const formatDuration = (seconds) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  return html`
    <div class="chart-section">
      <div class="chart-header">
        <div>
          <h3 class="chart-title">Content Needing Improvement</h3>
          <p class="chart-subtitle">Sorted by engagement score (low to high)</p>
        </div>
        <div class="chart-actions">
          <button type="button" onclick="exportChartData('lowEngagement')">Export</button>
        </div>
      </div>
      <div class="table-container">
        <table class="data-table">
          <thead>
            <tr>
              <th>Page Title</th>
              <th class="numeric">Views</th>
              <th class="numeric">Bounce</th>
              <th class="numeric">Scroll</th>
              <th class="numeric">Avg Time</th>
              <th class="numeric">Score</th>
            </tr>
          </thead>
          <tbody>
            ${data.map(row => html`
              <tr>
                <td title="${row.title}">${row.title.length > 35 ? row.title.substring(0, 32) + '...' : row.title}</td>
                <td class="numeric">${row.views}</td>
                <td class="numeric">${row.bounceRate}%</td>
                <td class="numeric">${row.avgScrollDepth}%</td>
                <td class="numeric">${formatDuration(row.avgDuration)}</td>
                <td class="numeric ${getScoreClass(row.engagementScore)}">${row.engagementScore}/100</td>
              </tr>
            `)}
          </tbody>
        </table>
      </div>
    </div>
  `;
}

lowEngagementTable

User Journey Flows

Show code
pathsSection = {
  if (!analyticsData || !analyticsData.paths.length) {
    return html`<div class="no-data">No path data available</div>`;
  }

  const data = analyticsData.paths;

  return html`
    <div class="chart-section">
      <div class="chart-header">
        <div>
          <h3 class="chart-title">Most Common User Paths</h3>
          <p class="chart-subtitle">Page-to-page navigation patterns</p>
        </div>
      </div>
      <div style="max-height: 350px; overflow-y: auto;">
        ${data.map((path, i) => html`
          <div class="path-flow">
            <span style="color: #7F8C8D; min-width: 20px;">#${i + 1}</span>
            <span class="path-from" title="${path.fromTitle}">${path.fromTitle}</span>
            <span class="path-arrow">→</span>
            <span class="path-to" title="${path.toTitle}">${path.toTitle}</span>
            <span class="path-count">${path.count}</span>
          </div>
        `)}
      </div>
    </div>
  `;
}

pathsSection

Content by Category

Show code
categoryChart = {
  if (!analyticsData || !analyticsData.popular.length) {
    return html``;
  }

  // Group popular content by part
  const byPart = {};
  analyticsData.popular.forEach(item => {
    const part = item.part || 'Other';
    byPart[part] = (byPart[part] || 0) + item.views;
  });

  const data = Object.entries(byPart)
    .map(([part, views]) => ({ part, views }))
    .sort((a, b) => b.views - a.views);

  return html`
    <div class="chart-section">
      <div class="chart-header">
        <div>
          <h3 class="chart-title">Views by Content Category</h3>
          <p class="chart-subtitle">Distribution across book parts</p>
        </div>
      </div>
      ${Plot.plot({
        width: 800,
        height: 300,
        marginBottom: 60,
        x: {
          label: null,
          tickRotate: -30
        },
        y: {
          label: "Total Views",
          grid: true
        },
        marks: [
          Plot.barY(data, {
            x: "part",
            y: "views",
            fill: d => {
              const colors = ["#2C3E50", "#16A085", "#E67E22", "#9B59B6", "#3498DB", "#27AE60", "#E74C3C", "#F39C12"];
              return colors[data.indexOf(d) % colors.length];
            },
            tip: true
          }),
          Plot.text(data, {
            x: "part",
            y: d => d.views,
            text: d => d.views.toLocaleString(),
            dy: -8,
            fontSize: 12,
            fontWeight: 600
          })
        ]
      })}
    </div>
  `;
}

categoryChart

Data Export

Show code
exportSection = html`
<div class="chart-section">
  <div class="chart-header">
    <div>
      <h3 class="chart-title">Export Analytics Data</h3>
      <p class="chart-subtitle">Download data for further analysis</p>
    </div>
  </div>
  <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
    <button type="button"
      style="padding: 0.75rem 1.5rem; background: #2C3E50; color: white; border: none; border-radius: 6px; cursor: pointer;"
      onclick="downloadExport('page_views')">
      Export Page Views (CSV)
    </button>
    <button type="button"
      style="padding: 0.75rem 1.5rem; background: #16A085; color: white; border: none; border-radius: 6px; cursor: pointer;"
      onclick="downloadExport('popular')">
      Export Popular Content (CSV)
    </button>
    <button type="button"
      style="padding: 0.75rem 1.5rem; background: #E67E22; color: white; border: none; border-radius: 6px; cursor: pointer;"
      onclick="downloadExport('engagement')">
      Export Engagement Metrics (CSV)
    </button>
  </div>
</div>
`

exportSection
Understanding the Metrics
  • Bounce Rate: Percentage of visitors who leave within 30 seconds without scrolling
  • Scroll Depth: How far down the page users scroll on average
  • Completion Rate: Percentage of users who scroll past 75% of the content
  • Engagement Score: Combined metric (0-100) based on bounce rate, scroll depth, and time on page
Data Collection

Analytics data is collected in real-time as users browse the platform. All data is anonymized and used only to improve the learning experience. Page views are tracked with timestamp, duration, and scroll depth to help identify which content resonates most with learners.

Back to top