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

Progress

Your XP, level, streak, and every badge you’ve earned or can still earn — all in one place.

My progress Badges How badges & progress work
Preparing your dashboard

Loading your progress and achievements.

My progress

Show code
dashboardServices = {
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  const waitFor = async (getter, timeoutMs = 5000) => {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      const value = getter();
      if (value) {
        return value;
      }
      await wait(100);
    }
    return null;
  };

  const auth = await waitFor(() => {
    const candidate = window.Auth;
    return candidate &&
      typeof candidate.getCurrentUser === 'function' &&
      typeof candidate.isUserAuthenticated === 'function'
      ? candidate
      : null;
  });

  const api = await waitFor(() => {
    const candidate = window.IoTClassAPI;
    return candidate && candidate.Badge ? candidate : null;
  });

  const progress = await waitFor(() => {
    const candidate = window.UnifiedProgress;
    return candidate &&
      typeof candidate.loadFromCloud === 'function' &&
      typeof candidate.getAllProgress === 'function'
      ? candidate
      : null;
  }, 3000);

  const badgeSystem = await waitFor(() => {
    const candidate = window.BadgeSystem;
    return candidate && typeof candidate.init === 'function' ? candidate : null;
  }, 2000);

  const userProfile = await waitFor(() => {
    const candidate = window.UserProfile;
    return candidate && typeof candidate.init === 'function' ? candidate : null;
  }, 2000);

  return {
    auth,
    api,
    progress,
    badgeSystem,
    userProfile,
  };
}

// Check authentication and load user data
userData = {
  const auth = dashboardServices.auth;
  const api = dashboardServices.api;
  const progress = dashboardServices.progress;
  const user = auth?.getCurrentUser?.();

  if (!user) {
    const authRegion = document.getElementById('auth-check');
    authRegion.innerHTML = `
      <section class="dashboard-signin-card" aria-labelledby="dashboard-signin-title">
        <div class="dashboard-signin-copy">
          <span class="dashboard-eyebrow">Your learning space</span>
          <h2 id="dashboard-signin-title">Pick up where you left off</h2>
          <p>Sign in to sync your XP, badges, streak, and completed chapters across devices.</p>
          <button type="button" class="dashboard-primary-button" id="dashboard-ojs-login">
            <span class="dashboard-google-mark" aria-hidden="true">G</span>
            Sign in with Google
          </button>
        </div>
        <div class="dashboard-signin-preview" aria-hidden="true">
          <span class="dashboard-preview-label">Your next chapter</span>
          <strong>Continue your IoT learning path</strong>
          <span class="dashboard-preview-track"><span></span></span>
          <small>Progress follows you when you sign in.</small>
        </div>
      </section>
    `;
    authRegion.querySelector('#dashboard-ojs-login')?.addEventListener('click', () => {
      window.Auth?.login?.('google');
    });
    return null;
  }

  // User is authenticated, load data from Supabase via UnifiedProgress
  let totalXP = 0;
  let level = 1;
  let streak = 0;
  let progressData = [];
  let badgesData = [];

  try {
    // Mastery is evaluated before the badge read so a newly earned crown can
    // appear immediately. The award API and its database key make this safe
    // to repeat on every dashboard visit.
    await window.MasteryAwards?.evaluateCurrentUser?.();

    // Get XP and level from UnifiedProgress (syncs with Supabase)
    if (progress) {
      await progress.loadFromCloud();
      totalXP = progress.getXP();
      level = progress.getLevel();
      streak = progress.getStreak();
      progressData = await progress.getAllProgress() || [];
    }

    // Get badges from Supabase API
    if (api?.Badge?.getUserBadges) {
      badgesData = await api.Badge.getUserBadges(user.id) || [];
    }
  } catch (err) {
    console.warn('Error loading dashboard data:', err);
  }

  document.getElementById('auth-check').hidden = true;
  document.getElementById('dashboard-content').hidden = false;

  // Count completed chapters
  const completedChapters = Array.isArray(progressData)
    ? progressData.filter(p => p.completed || p.progress_percent === 100)
    : [];

  return {
    user: user,
    totalXP: totalXP || 0,
    level: level || 1,
    streak: streak || 0,
    chaptersCompleted: completedChapters.length,
    badgesEarned: badgesData.length,
    joinDate: user.created_at || new Date().toISOString(),
    lastActive: new Date().toISOString(),
    progress: completedChapters,
    badges: badgesData,
    masteryBadges: badgesData.filter((badge) => {
      const id = badge.badge_id || badge.id || '';
      return id.startsWith('module-mastery-') || id.startsWith('path-mastery-');
    })
  };
}

// Semantic fallback dashboard. UserProfile enhances this surface when available.
html`
  ${userData ? html`
    <div class="dashboard-ojs-shell">
      <section class="dashboard-ojs-profile" aria-label="Learner profile">
        <img
          class="dashboard-ojs-avatar"
          src="${userData.user.avatar || '/images/characters/sammy.webp'}"
          alt="${userData.user.name || 'Learner'}"
        >
        <div class="dashboard-ojs-identity">
          <span class="dashboard-eyebrow">Learning dashboard</span>
          <h2>${userData.user.name || 'Learner'}</h2>
          <p>@${userData.user.login || 'iotclass-learner'}</p>
        </div>
      </section>

      <section class="dashboard-stat-grid" aria-label="Learning progress summary">
        <article class="dashboard-stat">
          <span>Level</span>
          <strong>${userData.level}</strong>
          <small>Your current stage</small>
        </article>
        <article class="dashboard-stat">
          <span>Total XP</span>
          <strong>${userData.totalXP.toLocaleString()}</strong>
          <small>Across all activities</small>
        </article>
        <article class="dashboard-stat">
          <span>Day streak</span>
          <strong>${userData.streak || 0}</strong>
          <small>Keep the rhythm going</small>
        </article>
        <article class="dashboard-stat">
          <span>Chapters</span>
          <strong>${userData.chaptersCompleted}</strong>
          <small>Completed chapters</small>
        </article>
      </section>

      ${(() => {
        const currentLevelXP = (userData.level - 1) * (userData.level - 1) * 100;
        const nextLevelXP = userData.level * userData.level * 100;
        const xpProgress = Math.max(0, userData.totalXP - currentLevelXP);
        const xpNeeded = nextLevelXP - currentLevelXP;
        const progressPercent = Math.min((xpProgress / xpNeeded) * 100, 100);

        return html`
          <section class="dashboard-panel dashboard-level-panel">
            <div class="dashboard-section-heading">
              <div>
                <span class="dashboard-eyebrow">Level ${userData.level}</span>
                <h3>Progress to Level ${userData.level + 1}</h3>
              </div>
              <strong>${Math.round(progressPercent)}%</strong>
            </div>
            <progress
              class="dashboard-progress"
              value="${progressPercent}"
              max="100"
              aria-label="Level progress"
            ></progress>
            <p>${xpProgress.toLocaleString()} of ${xpNeeded.toLocaleString()} XP earned toward the next level.</p>
          </section>
        `;
      })()}

      ${userData.masteryBadges.length > 0 ? html`
        <section class="dashboard-panel dashboard-mastery-panel" aria-labelledby="dashboard-mastery-title">
          <div class="dashboard-section-heading">
            <div>
              <span class="dashboard-eyebrow">Rarest reward tier</span>
              <h3 id="dashboard-mastery-title">Mastery earned</h3>
              <p>Complete coverage, recognised at this point in an evolving curriculum. There is always more to learn and more to earn.</p>
            </div>
            <strong>${userData.masteryBadges.length}</strong>
          </div>
          <div class="dashboard-mastery-list">
            ${userData.masteryBadges.map(badge => {
              const badgeId = badge.badge_id || badge.id || '';
              const badgeName = badge.badge_name || badge.badgeName || badgeId || 'Mastery';
              const earnedDate = badge.earned_at || badge.earnedDate || new Date().toISOString();
              const badgeIcon = badge.badge_icon || badge.icon_url ||
                `/assets/badges/art/${badgeId}-256.png`;
              const tier = badgeId.startsWith('path-mastery-') ? 'Path mastery' : 'Module mastery';
              return html`
                <article class="dashboard-mastery-row">
                  <img class="dashboard-mastery-art" src="${badgeIcon}" alt="">
                  <div class="dashboard-mastery-copy">
                    <span class="dashboard-mastery-tier">${tier}</span>
                    <strong>${badgeName}</strong>
                    <span>Earned ${new Date(earnedDate).toLocaleDateString()}</span>
                  </div>
                  <a class="dashboard-mastery-detail" href="#${badgeId}">
                    View mastery details
                  </a>
                </article>
              `;
            })}
          </div>
        </section>
      ` : ""}

      ${userData.badgesEarned > 0 ? html`
        <section class="dashboard-panel">
          <div class="dashboard-section-heading">
            <div>
              <span class="dashboard-eyebrow">Achievements</span>
              <h3>Your badges</h3>
            </div>
            <a href="#progress-badges">View showcase</a>
          </div>
          <div class="dashboard-badge-grid">
            ${userData.badges.filter((badge) => {
              const id = badge.badge_id || badge.id || '';
              return !id.startsWith('module-mastery-') && !id.startsWith('path-mastery-');
            }).map(badge => {
              const badgeName = badge.badge_name || badge.badgeName || badge.badge_id || 'Badge';
              const earnedDate = badge.earned_at || badge.earnedDate || new Date().toISOString();
              const badgeIcon = badge.icon_url || '/assets/badges/module-fundamentals.svg';
              return html`
                <article class="dashboard-badge-card">
                  <img src="${badgeIcon}" alt="">
                  <strong>${badgeName}</strong>
                  <small>Earned ${new Date(earnedDate).toLocaleDateString()}</small>
                </article>
              `;
            })}
          </div>
        </section>
      ` : ""}

      ${userData.chaptersCompleted > 0 ? html`
        <section class="dashboard-panel">
          <div class="dashboard-section-heading">
            <div>
              <span class="dashboard-eyebrow">Your momentum</span>
              <h3>Recent activity</h3>
            </div>
          </div>
          <ul class="dashboard-activity-list">
            ${userData.progress.slice(0, 5).map(p => {
              // Handle both old format (chapterPath) and new Supabase format (content_id)
              const contentPath = p.content_id || p.chapterPath || 'Unknown';
              const completedDate = p.completed_at || p.completedDate || new Date().toISOString();
              const displayName = contentPath
                .split('/')
                .pop()
                .replace('.html', '')
                .replace('.qmd', '')
                .replace(/-/g, ' ');
              return html`
                <li>
                  <span class="dashboard-activity-mark" aria-hidden="true"></span>
                  <span>
                    <strong>${displayName}</strong>
                    <small>Completed ${new Date(completedDate).toLocaleDateString()}</small>
                  </span>
                  <span class="dashboard-xp-chip">+100 XP</span>
                </li>
              `;
            })}
          </ul>
        </section>
      ` : ""}

      <nav class="dashboard-shortcuts" aria-label="Dashboard shortcuts">
        <a href="../apps/content-hub/index.html">Games</a>
        <a href="../apps/content-hub/index.html">Simulations</a>
        <a href="../apps/content-hub/index.html">Labs</a>
        <a href="#progress-badges">Badge showcase</a>
        <a href="/hubs/knowledge-gaps.html">Knowledge gaps</a>
        <a href="/paths.html">Learning paths</a>
        <a href="/how-it-works/badges-and-progress.html">How progress works</a>
      </nav>
    </div>
  ` : ""}
`
Your learning path

Advanced role paths

Browse all paths

Loading your learning paths…

Build momentum

How to earn more XP

Small steps add up. Complete a chapter, test your understanding, or keep a weekly streak.

  • +100 XPComplete a chapter
  • +5–15 XPAnswer a knowledge check
  • +150 XPEarn a 7-day streak badge

Badge collection

Every badge on IoTClass has its own illustration — 1,090 designs in all: 983 chapter badges, 41 module badges, 41 module-mastery crowns, 5 learning-path badges, 5 path-mastery crowns, and 15 milestone, streak and quiz badges.

Badges are free. Sign in with Google and they attach to your account instead of to one browser, so your XP, level, streak and every badge you earn follow you between devices. Nothing here expires and nothing here can be taken away.

This page is the gallery — what the artwork looks like and what earns it. For the full mechanics (every XP value, how streaks count, what happens when a chapter you already completed gets updated), read Badges & Progress in the How IoTClass Works guide.

Show code
supportServices = {
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  const waitFor = async (getter, timeoutMs = 5000) => {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      const value = getter();
      if (value) {
        return value;
      }
      await wait(100);
    }
    return null;
  };

  const auth = await waitFor(() => {
    const candidate = window.Auth;
    return candidate &&
      typeof candidate.getCurrentUser === 'function' &&
      typeof candidate.isUserAuthenticated === 'function'
      ? candidate
      : null;
  });

  const api = await waitFor(() => {
    const candidate = window.IoTClassAPI;
    return candidate && candidate.User && candidate.Badge ? candidate : null;
  });

  const badgeSystem = await waitFor(() => {
    const candidate = window.BadgeSystem;
    return candidate && (candidate.badgeCatalog || typeof candidate.init === 'function')
      ? candidate
      : null;
  }, 3000);

  return {
    auth,
    api,
    badgeSystem,
    isAuthenticated: Boolean(auth?.isUserAuthenticated?.()),
  };
}

// Colors
colors = ({
  navy: '#2C3E50',
  teal: '#0F766E',
  orange: '#E67E22',
  gray: '#7F8C8D',
  lightGray: '#ECF0F1'
})

// A 1,090-definition catalog is too large to paint in one pass, so the
// signed-in grid renders a capped page and reports the full total.
MAX_RENDERED_BADGES = 96

normalizeCategoryKey = (value) => {
  return String(value || 'uncategorized')
    .trim()
    .toLowerCase()
    .replace(/[_\s]+/g, '-');
}

formatCategoryLabel = (key) => {
  return key
    .split('-')
    .filter(Boolean)
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

badgeIconSvg = (kind = 'award') => {
  const shapes = {
    award: '<circle cx="12" cy="8" r="5"></circle><path d="m8.5 12-1 9 4.5-2.5 4.5 2.5-1-9"></path>',
    lock: '<rect x="5" y="10" width="14" height="11" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path>'
  };
  return `<svg class="badge-inline-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${shapes[kind] || shapes.award}</svg>`;
}

renderBadgeArt = (badge) => {
  const badgeSystem = supportServices.badgeSystem;
  if (badgeSystem?.renderBadgeImage) {
    return badgeSystem.renderBadgeImage(badge, {
      size: 256,
      alt: badge.name || badge.badge_name || 'IoTClass badge',
      className: 'badge-art-image'
    });
  }
  return badgeIconSvg('award');
}

// Check authentication
currentUser = {
  const auth = supportServices.auth;
  if (!auth) {
    return null;
  }

  const user = auth.getCurrentUser();
  if (!user) {
    return null;
  }

  // Get user profile from Supabase
  const userApi = supportServices.api?.User;
  const userData = userApi?.getProfile
    ? await userApi.getProfile(user.id)
    : {};

  return {
    ...user,
    ...userData
  };
}

// Full badge catalog from badge_definitions
badgeCatalog = {
  const badgeSystem = supportServices.badgeSystem;
  if (badgeSystem?.init) {
    await badgeSystem.init();
  }

  if (Array.isArray(badgeSystem?.badgeCatalog) && badgeSystem.badgeCatalog.length > 0) {
    return badgeSystem.badgeCatalog.map(badge => ({
      ...badge,
      xp_reward: badge.xp_reward || 0
    }));
  }

  const badgeApi = supportServices.api?.Badge;
  if (badgeApi?.getAllBadgeDefinitions) {
    const definitions = await badgeApi.getAllBadgeDefinitions();
    if (definitions && definitions.length > 0) {
      return definitions.map(badge => ({
        ...badge,
        xp_reward: badge.xp_reward || 0
      }));
    }
  }

  return [];
}

// User's earned badges - Using Supabase via BadgeSystem or direct API
userBadges = {
  if (!supportServices.isAuthenticated || !currentUser) {
    return [];
  }

  try {
    const badgeSystem = supportServices.badgeSystem;
    const badgeApi = supportServices.api?.Badge;

    // Try BadgeSystem first (it may have more details)
    if (badgeSystem?.getUserBadgesWithDetails) {
      const badges = await badgeSystem.getUserBadgesWithDetails(currentUser.id);
      return badges;
    }

    // Fallback to direct Supabase API
    if (!badgeApi?.getUserBadges) {
      return [];
    }

    const badges = await badgeApi.getUserBadges(currentUser.id);
    return badges.map(b => ({
      ...b,
      badgeId: b.badge_id,
      earnedDate: b.earned_at
    }));
  } catch (error) {
    console.error('Error loading user badges:', error);
    return [];
  }
}

categoryMeta = {
  const defaults = {
    chapter: { label: 'Chapter', accent: '#0F766E' },
    module: { label: 'Module', accent: '#2C3E50' },
    mastery: { label: 'Mastery', accent: '#B7791F' },
    'learning-path': { label: 'Learning Path', accent: '#1D4ED8' },
    milestone: { label: 'Milestone', accent: '#E67E22' },
    achievement: { label: 'Achievement', accent: '#16A085' },
    streak: { label: 'Streak', accent: '#C0392B' },
    specialty: { label: 'Specialty', accent: '#6B21A8' },
    special: { label: 'Special', accent: '#8E44AD' },
    uncategorized: { label: 'Other', accent: '#7F8C8D' }
  };

  const meta = {};
  const register = (rawCategory) => {
    const key = normalizeCategoryKey(rawCategory);
    if (!meta[key]) {
      const fallback = defaults.uncategorized;
      meta[key] = defaults[key] || {
        label: formatCategoryLabel(key),
        accent: fallback.accent
      };
    }
    return key;
  };

  badgeCatalog.forEach(badge => register(badge.category));
  userBadges.forEach(badge => {
    register(
      badge.category ||
      badge.badge_category ||
      badge.badgeCategory ||
      badge.badge_definition?.category ||
      badge.badgeDefinition?.category
    );
  });

  if (Object.keys(meta).length === 0) {
    register('milestone');
  }

  return meta;
}

// Badge progress statistics
badgeStats = {
  const earnedCount = userBadges.length;
  const totalCount = badgeCatalog.length;
  const completionPercent = totalCount > 0 ? Math.round((earnedCount / totalCount) * 100) : 0;

  const makeBucket = (key) => ({
    earned: 0,
    total: 0,
    label: categoryMeta[key]?.label || formatCategoryLabel(key),
    accent: categoryMeta[key]?.accent || colors.gray
  });

  const byCategory = {};
  Object.keys(categoryMeta).forEach((key) => {
    byCategory[key] = makeBucket(key);
  });

  const resolveBadgeCategory = (badge) => {
    return normalizeCategoryKey(
      badge.category ||
      badge.badge_category ||
      badge.badgeCategory ||
      badge.badge_definition?.category ||
      badge.badgeDefinition?.category
    );
  };

  badgeCatalog.forEach((badge) => {
    const key = resolveBadgeCategory(badge);
    if (!byCategory[key]) {
      byCategory[key] = makeBucket(key);
    }
    byCategory[key].total++;
  });

  userBadges.forEach((badge) => {
    const key = resolveBadgeCategory(badge);
    if (!byCategory[key]) {
      byCategory[key] = makeBucket(key);
    }
    byCategory[key].earned++;
  });

  // Total XP from badges
  const totalBadgeXP = userBadges.reduce((sum, badge) => sum + (badge.xp_reward || 0), 0);
  const categoryKeys = Object.keys(byCategory)
    .filter((key) => byCategory[key].total > 0 || byCategory[key].earned > 0)
    .sort((left, right) => {
      return byCategory[right].total - byCategory[left].total || left.localeCompare(right);
    });

  return {
    earnedCount,
    totalCount,
    completionPercent,
    byCategory,
    categoryKeys,
    totalBadgeXP
  };
}

// Filter state.
//
// These are `mutable` (not plain) cells: the view-tabs/category-filter cell
// below registers real addEventListener click handlers on the rendered
// buttons, and those handlers reassign `mutable view`/`mutable
// categoryFilter` to change state. Inline HTML onclick="..." attributes
// cannot do this — Quarto's OJS runtime parses cell-returned HTML strings
// with the browser's own HTML parser, so any onclick attribute in that
// markup compiles to a handler evaluated in global/window scope, which has
// no visibility into an OJS module binding. `mutable` cells are the
// documented Observable/OJS mechanism for state a DOM event handler
// (registered from inside an OJS cell, so it's a real JS closure rather
// than a re-parsed attribute string) can update and have dependent cells
// (filteredBadges, visibleBadges, the tabs/grid cells below) re-run
// reactively.
mutable view = 'all' // all, earned, not-earned

mutable categoryFilter = 'all' // all or a normalized category key

// Filtered badges
filteredBadges = {
  const earnedBadgeIds = new Set(
    userBadges.map((badge) => badge.badgeId || badge.badge_id || badge.id).filter(Boolean)
  );
  let badges = badgeCatalog;

  // Apply view filter
  if (view === 'earned') {
    badges = badges.filter(badge =>
      earnedBadgeIds.has(badge.id)
    );
  } else if (view === 'not-earned') {
    badges = badges.filter(badge =>
      !earnedBadgeIds.has(badge.id)
    );
  }

  // Apply category filter
  if (categoryFilter !== 'all') {
    badges = badges.filter(badge => normalizeCategoryKey(badge.category) === categoryFilter);
  }

  // Merge with earned data, then surface what you already hold first.
  return badges.map(badge => {
    const earned = userBadges.find(e => (e.badgeId || e.badge_id || e.id) === badge.id);
    const categoryKey = normalizeCategoryKey(badge.category);
    const categoryInfo = categoryMeta[categoryKey] || {
      label: formatCategoryLabel(categoryKey),
      accent: colors.gray
    };
    return {
      ...badge,
      category: categoryKey,
      categoryLabel: categoryInfo.label,
      categoryAccent: categoryInfo.accent,
      earned: earnedBadgeIds.has(badge.id),
      earnedDate: earned?.earnedDate || earned?.earned_at
    };
  }).sort((left, right) => {
    if (left.earned !== right.earned) return left.earned ? -1 : 1;
    return (right.xp_reward || 0) - (left.xp_reward || 0) ||
      String(left.name || '').localeCompare(String(right.name || ''));
  });
}

// The live catalog runs to ~1,090 definitions. Painting all of them at once
// stalls the page, so the grid renders a capped page (earned badges first)
// and reports the full total underneath.
visibleBadges = filteredBadges.slice(0, MAX_RENDERED_BADGES)
Show code
// Signed-out call to action.
html`${!supportServices.isAuthenticated ? `
  <div class="badge-signin-panel">
    <div class="badge-signin-icon">${badgeIconSvg('lock')}</div>
    <div class="badge-signin-title" role="heading" aria-level="2">Sign in to Earn Badges</div>
    <p class="badge-signin-copy">
      Free with Google. Your XP, level, streak and badges follow you between devices.
    </p>
    <button
      class="badge-signin-btn"
      onclick="window.Auth && typeof window.Auth.login === 'function' && window.Auth.login('google')"
    >
      <span aria-hidden="true">G</span> Sign in with Google
    </button>
  </div>
` : ''}`
Show code
// Signed-in progress summary, view tabs and category filters.
//
// The tabs/filter buttons used to carry onclick="viewState.view = 'all'"
// etc. Those never worked: this cell hands its whole markup to `html` as a
// single already-built string, so Quarto's OJS runtime parses it with the
// browser's native HTML parser (document.createElement('template').innerHTML
// = string — see quarto-ojs-runtime.js's renderHtml/hypertext), which turns
// onclick="..." into an inline handler compiled against global/window
// scope. `viewState` is an OJS module binding, not a global, so every click
// threw ReferenceError.
//
// Fix: the buttons below carry data-view/data-category-filter attributes
// instead of onclick, and real addEventListener listeners are registered
// on them here, inside this OJS cell, after the markup is parsed into a
// live DOM node (querySelectorAll/addEventListener both work on a detached
// DocumentFragment before it's inserted into the page). Because these
// listeners are genuine closures created directly in this cell's own JS
// source, `mutable view = ...` / `mutable categoryFilter = ...` inside them
// are real, in-scope references to the mutable cells above — Observable's
// parser resolves `mutable <name> = <expr>` wherever it appears in a cell's
// source, and reassigning it re-runs every dependent cell (filteredBadges →
// visibleBadges → the grid cell below).
viewTabsPanel = {
  const node = html`${supportServices.isAuthenticated ? `
  <div class="progress-section">
    <h3>Your Badge Progress</h3>

    <div class="progress-stats">
      <div class="progress-stat">
        <div class="progress-stat-value">${badgeStats.earnedCount}/${badgeStats.totalCount}</div>
        <div class="progress-stat-label">Badges Earned</div>
      </div>

      <div class="progress-stat">
        <div class="progress-stat-value">${badgeStats.completionPercent}%</div>
        <div class="progress-stat-label">Completion</div>
      </div>

      <div class="progress-stat">
        <div class="progress-stat-value">${badgeStats.totalBadgeXP.toLocaleString()}</div>
        <div class="progress-stat-label">XP from Badges</div>
      </div>

      ${badgeStats.categoryKeys.map(key => `
        <div class="progress-stat">
          <div class="progress-stat-value">
            ${badgeStats.byCategory[key].earned}/${badgeStats.byCategory[key].total}
          </div>
          <div class="progress-stat-label">
            ${badgeStats.byCategory[key].label}
          </div>
        </div>
      `).join('')}
    </div>

    <!-- View Tabs -->
    <div class="badge-tabs">
      <button class="badge-tab ${view === 'all' ? 'active' : ''}" data-view="all">
        All Badges (${badgeCatalog.length})
      </button>
      <button class="badge-tab ${view === 'earned' ? 'active' : ''}" data-view="earned">
        Earned (${badgeStats.earnedCount})
      </button>
      <button class="badge-tab ${view === 'not-earned' ? 'active' : ''}" data-view="not-earned">
        Still to Earn (${badgeCatalog.length - badgeStats.earnedCount})
      </button>
    </div>

    <!-- Category Filters -->
    <div class="category-filter">
      <button class="filter-btn ${categoryFilter === 'all' ? 'active' : ''}" data-category-filter="all">
        All Categories
      </button>
      ${badgeStats.categoryKeys.map(key => `
        <button class="filter-btn ${categoryFilter === key ? 'active' : ''}" data-category-filter="${key}">
          ${badgeStats.byCategory[key].label}
        </button>
      `).join('')}
    </div>
  </div>
` : ''}`;

  // html`` returns null (not an empty fragment) when its content resolves
  // to nothing — e.g. every time this renders signed-out, since the ternary
  // above yields ''. Guard on node itself, not just the method lookup.
  node?.querySelectorAll('[data-view]').forEach((btn) => {
    btn.addEventListener('click', () => {
      mutable view = btn.dataset.view;
    });
  });

  node?.querySelectorAll('[data-category-filter]').forEach((btn) => {
    btn.addEventListener('click', () => {
      mutable categoryFilter = btn.dataset.categoryFilter;
    });
  });

  return node;
}
Show code
// Signed-in badge grid, populated at runtime from badge_definitions.
html`${supportServices.isAuthenticated ? (
  visibleBadges.length === 0 ? `
  <div class="badge-empty-state">
    <div class="badge-empty-state-icon">${badgeIconSvg('award')}</div>
    <h3 class="badge-empty-state-title">Nothing here yet</h3>
    <p class="badge-empty-state-copy">
      No badges match this view. Complete a chapter's knowledge checks to earn
      your first chapter badge, then browse the gallery below for what comes next.
    </p>
  </div>
` : `
  <div class="badges-grid">
    ${visibleBadges.map(badge => {
      const categoryClass = `category-${badge.category}`;

      return `
        <div class="badge-card ${badge.earned ? 'earned' : 'locked'}">
          <div class="badge-emoji ${!badge.earned ? 'badge-locked-emoji' : ''}">
            ${renderBadgeArt(badge)}
          </div>

          <h3 class="badge-name">${badge.name}</h3>

          <div class="badge-category ${categoryClass}" style="background: ${badge.categoryAccent}; color: white;">
            ${badge.categoryLabel}
          </div>

          <div class="badge-xp-reward">
            +${badge.xp_reward} XP
          </div>

          <p class="badge-criteria">
            ${badge.description || badge.earning_logic?.criteria || badge.criteria || 'Complete the requirements to earn this badge'}
          </p>

          ${badge.earned ? `
            <div class="badge-earned-date">
              Earned: ${new Date(badge.earnedDate).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
            </div>
          ` : `
            <div class="badge-card-status">
              ${badgeIconSvg('lock')} Not earned yet
            </div>
          `}
        </div>
      `;
    }).join('')}
  </div>
  ${filteredBadges.length > visibleBadges.length ? `
    <p class="badge-card-status">
      Showing ${visibleBadges.length} of ${filteredBadges.length} badges in this view.
      Narrow it with a category filter above.
    </p>
  ` : ''}
`) : ''}`

From One Chapter to the Crown

Four tiers, and each one is built out of the tier below it. Nothing is awarded for time spent or pages scrolled — every badge below is issued against work the platform can verify.

MQTT QoS Levels chapter badge

Chapter badge

Answer every knowledge check correctly and reach every required checkpoint in one chapter. Worth 20 XP per required knowledge check plus 30 XP per checkpoint, never less than 25. Shown: MQTT QoS Levels.

+60 XP · one of 983
MQTT module badge

Module badge

Earn the chapter badge for every assessable chapter in a module. Worth 200 XP plus 25 XP for each of those chapters. Shown: MQTT, which covers 13 chapters.

+525 XP · one of 41
MQTT module mastery badge, crowned

Module mastery

Complete every chapter in the module, including the reading-only ones that carry no checks. The crown is deliberately harder to reach than the module badge beneath it.

+500 XP · one of 41
University IoT Curriculum path mastery badge, crowned

Path mastery

Reach module mastery in every module a learning path covers. Shown: the University IoT Curriculum crown, which spans 30 modules.

+1,500 XP · one of 5

Start With These

Three that are reachable in a single session: finish a chapter, finish a quiz, and ace one.

First Steps badge: a learner reading an open book beside a check mark

First Steps

Complete your first chapter.

+50 XP
Quiz Taker badge: a learner holding a question card marked with a tick

Quiz Taker

Complete your first quiz.

+25 XP
Perfect Score badge

Perfect Score

Score 100% on a quiz.

+100 XP

Chapter-Count Milestones

These four track raw distance covered, whichever chapters you pick. They arrive automatically as your completed-chapter count crosses each threshold.

Curious Learner badge

Curious Learner

Complete 5 chapters.

+100 XP
Dedicated Student badge

Dedicated Student

Complete 10 chapters.

+200 XP
IoT Enthusiast badge

IoT Enthusiast

Complete 25 chapters.

+500 XP
IoT Expert badge

IoT Expert

Complete 50 chapters.

+1,000 XP

Streaks

Your streak advances when you return on a new learning day. Come back after a gap and the current streak starts again at one — there is no freeze and no repair token. Your dashboard keeps both your current and your longest streak.

On Fire badge: a numeral 3 under a lit flame

On Fire

Return on three consecutive learning days.

+50 XP
Week Warrior badge: a numeral 7 under a lit flame

Week Warrior

Return on seven consecutive learning days.

+150 XP
Monthly Master badge: a numeral 30 under a lit flame and a small crown

Monthly Master

Return on thirty consecutive learning days.

+500 XP

Every Module Has a Badge

All 41 modules carry one, and the XP scales with how much ground the module covers: 200 XP plus 25 XP for every assessable chapter. Eight of the 41 are shown here.

MQTT module badge

MQTT

Earn the chapter badge for all 13 chapters in this module.

+525 XP
Sensors and Measurement module badge

Sensors & Measurement

Earn the chapter badge for all 39 chapters in this module.

+1,175 XP
Electronics and Circuits module badge

Electronics & Circuits

Earn the chapter badge for all 29 chapters in this module.

+925 XP
Security: Threats and Defense module badge

Security: Threats & Defense

Earn the chapter badge for all 24 chapters in this module.

+800 XP
LoRa and LoRaWAN module badge

LoRa & LoRaWAN

Earn the chapter badge for all 19 chapters in this module.

+675 XP
Edge and Fog Computing module badge

Edge & Fog Computing

Earn the chapter badge for all 20 chapters in this module.

+700 XP
Analytics and ML module badge

Analytics & ML

Earn the chapter badge for all 51 chapters in this module.

+1,475 XP
Prototyping module badge

Prototyping

Earn the chapter badge for all 27 chapters in this module.

+875 XP

Finish a Learning Path

Five role-based paths run through the curriculum, from the Young IoT Explorer route to the full University IoT Curriculum. Completing one earns its graduate badge.

Young IoT Explorer learning path graduate badge

Young IoT Explorer Graduate

Complete the Young IoT Explorer learning path.

+100 XP
High School IoT Foundations learning path graduate badge

High School IoT Foundations Graduate

Complete the High School IoT Foundations learning path.

+100 XP
University IoT Curriculum learning path graduate badge

University IoT Curriculum Graduate

Complete the University IoT Curriculum learning path.

+100 XP
IoT Professional Practitioner learning path graduate badge

IoT Professional Practitioner Graduate

Complete the IoT Professional Practitioner learning path.

+100 XP
Executive IoT Strategy learning path graduate badge

Executive IoT Strategy Graduate

Complete the Executive IoT Strategy learning path.

+100 XP

The Crown Tier: Mastery

Mastery is the top of the system, and it is deliberately not a piece of paper you file away. A module badge asks you to clear every assessable chapter. Mastery asks for the whole module — including the reading-only chapters that carry no checks — which is why the crowned artwork is rarer than the badge underneath it.

It stays alive after you earn it. IoT practice moves, so chapters get revised; when content you already completed changes, your dashboard flags it, tells you what’s new, and completing it again pays a 20 XP knowledge refresh. Mastery records complete coverage at this point in an evolving curriculum. There is always more to earn.

41 module masteries — 500 XP each, for finishing every chapter in a module.

Actuators and Control module mastery badgeActuators & Control
AMQP module mastery badgeAMQP
Analytics and ML module mastery badgeAnalytics & ML
Application Protocols module mastery badgeApplication Protocols
Applications and Use Cases module mastery badgeApplications & Use Cases
Authentication and Access module mastery badgeAuthentication & Access
Bluetooth and BLE module mastery badgeBluetooth & BLE
Capstone and Resources module mastery badgeCapstone & Resources
Cellular IoT module mastery badgeCellular IoT
CoAP module mastery badgeCoAP
Cryptography module mastery badgeCryptography
Data Storage module mastery badgeData Storage
Design Methodology module mastery badgeDesign Methodology
Design Patterns module mastery badgeDesign Patterns
Edge and Fog Computing module mastery badgeEdge & Fog Computing
Electronics and Circuits module mastery badgeElectronics & Circuits
Emerging Paradigms module mastery badgeEmerging Paradigms
Energy and Power module mastery badgeEnergy & Power
IoT Fundamentals module mastery badgeIoT Fundamentals
Integration and Gateways module mastery badgeIntegration & Gateways
LoRa and LoRaWAN module mastery badgeLoRa & LoRaWAN
MQTT module mastery badgeMQTT
Network Topologies module mastery badgeNetwork Topologies
Core Networking module mastery badgeCore Networking
Privacy and Compliance module mastery badgePrivacy & Compliance
Prototyping module mastery badgePrototyping
Reference Architectures module mastery badgeReference Architectures
RFID, NFC and UWB module mastery badgeRFID, NFC & UWB
Routing and RPL module mastery badgeRouting & RPL
Security: Threats and Defense module mastery badgeSecurity: Threats & Defense
Sensor Applications module mastery badgeSensor Applications
Sensors and Measurement module mastery badgeSensors & Measurement
Specialized Architectures module mastery badgeSpecialized Architectures
Stream Processing module mastery badgeStream Processing
Testing and Validation module mastery badgeTesting & Validation
Transport Protocols module mastery badgeTransport Protocols
UX Design module mastery badgeUX Design
Visualization module mastery badgeVisualization
Wi-Fi and 802.11 module mastery badgeWi-Fi & 802.11
Wireless Sensor Networks module mastery badgeWireless Sensor Networks
Zigbee, Thread and Matter module mastery badgeZigbee, Thread & Matter

5 path masteries — 1,500 XP each, awarded once every module a path covers has reached module mastery.

Young IoT Explorer path mastery badgeYoung IoT Explorer
High School IoT Foundations path mastery badgeHigh School IoT Foundations
University IoT Curriculum path mastery badgeUniversity IoT Curriculum
IoT Professional Practitioner path mastery badgeIoT Professional Practitioner
Executive IoT Strategy path mastery badgeExecutive IoT Strategy

XP and Levels, Briefly

XP is a running record, not a score you can lose. A new profile opens with a 50 XP welcome bonus, and after that XP comes only from completed work — knowledge checks, chapters, badges, streaks and mastery. Every source is recorded once, so the same achievement can never pay twice.

Your level follows the XP total on a widening curve: level 2 at 100 XP, level 3 at 400 XP, level 4 at 900 XP, level 10 at 8,100 XP. Each level costs more than the last, so a level number always means roughly the same amount of verified work.

Where the Full Rules Live

This page shows the artwork. Everything else — the exact XP value of each knowledge-check difficulty, how the duplicate guards work, what the freshness markers on your dashboard mean — is spelled out in Badges & Progress.

Your own totals, streak, completed chapters and earned badges all live above, in My progress.

Back to top