Learner engagement can be monitored with a weighted score:
\[
E = 0.5\,Q + 0.3\,L + 0.2\,B
\]
where \(Q\) is quiz completion rate, \(L\) is lab completion rate, and \(B\) is badge progress (all normalized 0 to 1).
Worked example : For \(Q=0.82\) , \(L=0.55\) , and \(B=0.40\) , engagement score is \(E=0.5(0.82)+0.3(0.55)+0.2(0.40)=0.655\) . Tracking this weekly helps detect drop-off early.
📊 Your Learning Dashboard
Welcome to your personalized learning dashboard! Track your progress, view earned badges, and see your learning statistics.
IoTClass.org uses Supabase Auth for Google sign-in. The site code only needs the Supabase project URL and publishable key; Google OAuth client secrets must stay in the Supabase dashboard and must not be committed to this repository.
In Google Cloud Console, create an OAuth 2.0 Web application client.
Add the Supabase callback URL as an authorized redirect URI: https://<project-ref>.supabase.co/auth/v1/callback.
In Supabase, open Authentication > Providers > Google , enable Google, then paste the Google client ID and client secret.
In Supabase, open Authentication > URL Configuration and set the production site URL to https://iotclass.org/.
Add redirect URLs for every environment that should accept OAuth callbacks, such as https://iotclass.org/**, the GitHub Pages branch preview URL if used, and any local or proxy URL used for testing.
In assets/js/iotclass-config.js, keep authProvider: 'google', set the Supabase project URL and publishable key, and add administrator email addresses to adminEmails when admin pages should work with Google accounts.
Keep Row Level Security enabled on Supabase progress tables, with policies scoped to auth.uid() so each learner can read and write only their own progress rows.
Progress remains localStorage-first. Learners can build progress before signing in; after Google sign-in, UnifiedProgress.handleLogin() loads cloud state, flushes the local sync queue, and keeps future updates attached to the signed-in Supabase user ID.
<div style="font-size: 3em; margin-bottom: 20px;">⏳</div>
<p style="color: #7F8C8D;">Loading your dashboard...</p>
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,
};
}
// IEEE Color Palette
colors = ({
navy : "#2C3E50" ,
teal : "#16A085" ,
orange : "#E67E22" ,
gray : "#7F8C8D" ,
lightGray : "#ECF0F1" ,
darkGray : "#34495E" ,
green : "#27AE60" ,
red : "#E74C3C" ,
purple : "#9B59B6" ,
blue : "#3498DB" ,
white : "#FFFFFF"
})
// Check authentication and load user data
userData = {
const auth = dashboardServices. auth ;
const api = dashboardServices. api ;
const progress = dashboardServices. progress ;
const loginAction = "window.Auth && typeof window.Auth.login === 'function' && window.Auth.login('google')" ;
const user = auth?. getCurrentUser ?. ();
if (! user) {
document . getElementById ('auth-check' ). innerHTML = `
<div style="text-align: center; padding: 60px 20px; background: linear-gradient(135deg, ${ colors. navy } 0%, ${ colors. darkGray } 100%); border-radius: 12px; color: white;">
<div style="display: inline-block; margin-bottom: 18px; padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.12); color: white; font-size: 12px; font-weight: 700; letter-spacing: 0.08em;">LOGIN</div>
<h2 style="margin: 0 0 10px 0; color: white;">Sign In Required</h2>
<p style="margin: 0 0 25px 0; opacity: 0.9; color: white;">Please sign in to view your dashboard</p>
<button onclick=" ${ loginAction} " style="
padding: 12px 32px;
background: ${ colors. teal } ;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
">
<span aria-hidden="true" style="font-weight: 700; color: white;">G</span> Sign in with Google
</button>
</div>
` ;
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 {
// 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' ). style . display = 'none' ;
document . getElementById ('dashboard-content' ). style . display = 'block' ;
// 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
};
}
// Profile Header
html `
${ userData ? html `
<div style="
background: linear-gradient(135deg, ${ colors. navy } 0%, ${ colors. darkGray } 100%);
padding: 40px 20px;
border-radius: 12px;
color: white;
margin-bottom: 30px;
">
<div style="
display: flex;
align-items: center;
gap: 30px;
flex-wrap: wrap;
">
<img src=" ${ userData. user . avatar } "
alt=" ${ userData. user . name } "
style="
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
">
<div style="flex: 1;">
<h1 style="margin: 0 0 10px 0; font-size: 2.5em;"> ${ userData. user . name } </h1>
<p style="margin: 0 0 15px 0; opacity: 0.9; font-size: 1.1em;">@ ${ userData. user . login } </p>
<div style="
display: flex;
gap: 20px;
flex-wrap: wrap;
">
<div>
<div style="font-size: 1.8em; font-weight: bold;"> ${ userData. level } </div>
<div style="opacity: 0.8; font-size: 0.9em;">Level</div>
</div>
<div>
<div style="font-size: 1.8em; font-weight: bold;"> ${ userData. totalXP . toLocaleString ()} </div>
<div style="opacity: 0.8; font-size: 0.9em;">Total XP</div>
</div>
<div>
<div style="font-size: 1.8em; font-weight: bold;"> ${ userData. streak || 0 } </div>
<div style="opacity: 0.8; font-size: 0.9em;">Day Streak 🔥</div>
</div>
<div>
<div style="font-size: 1.8em; font-weight: bold;"> ${ userData. badgesEarned } </div>
<div style="opacity: 0.8; font-size: 0.9em;">Badges</div>
</div>
</div>
</div>
</div>
</div>
` : "" }
`
Show code
html `
${ userData ? html `
<div style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
">
<!-- Chapters Completed -->
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
">
<div style="font-size: 3em; margin-bottom: 10px;">📚</div>
<div style="
font-size: 2.5em;
font-weight: bold;
color: ${ colors. blue } ;
margin-bottom: 5px;
"> ${ userData. chaptersCompleted } </div>
<div style="color: ${ colors. gray } ;">Chapters Complete</div>
</div>
<!-- Total XP -->
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
">
<div style="font-size: 3em; margin-bottom: 10px;">⭐</div>
<div style="
font-size: 2.5em;
font-weight: bold;
color: ${ colors. orange } ;
margin-bottom: 5px;
"> ${ userData. totalXP . toLocaleString ()} </div>
<div style="color: ${ colors. gray } ;">Experience Points</div>
</div>
<!-- Level -->
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
">
<div style="font-size: 3em; margin-bottom: 10px;">🎯</div>
<div style="
font-size: 2.5em;
font-weight: bold;
color: ${ colors. teal } ;
margin-bottom: 5px;
"> ${ userData. level } </div>
<div style="color: ${ colors. gray } ;">Current Level</div>
</div>
<!-- Badges -->
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
">
<div style="font-size: 3em; margin-bottom: 10px;">🏆</div>
<div style="
font-size: 2.5em;
font-weight: bold;
color: ${ colors. purple } ;
margin-bottom: 5px;
"> ${ userData. badgesEarned } </div>
<div style="color: ${ colors. gray } ;">Badges Earned</div>
</div>
<!-- Streak -->
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
">
<div style="font-size: 3em; margin-bottom: 10px;">🔥</div>
<div style="
font-size: 2.5em;
font-weight: bold;
color: ${ colors. red } ;
margin-bottom: 5px;
"> ${ userData. streak || 0 } </div>
<div style="color: ${ colors. gray } ;">Day Streak</div>
</div>
</div>
` : "" }
`
Show code
html `
${ userData ? html `
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 30px;
">
<h2 style="margin: 0 0 20px 0; color: ${ colors. navy } ;">📈 Level Progress</h2>
${ (() => {
const currentLevelXP = (userData. level - 1 ) * (userData. level - 1 ) * 100 ;
const nextLevelXP = userData. level * userData. level * 100 ;
const xpProgress = userData. totalXP - currentLevelXP;
const xpNeeded = nextLevelXP - currentLevelXP;
const progressPercent = Math . min ((xpProgress / xpNeeded) * 100 , 100 );
return html `
<div style="margin-bottom: 15px;">
<div style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
">
<span style="font-weight: 500; font-size: 1.1em;">
Level ${ userData. level } → ${ userData. level + 1 }
</span>
<span style="color: ${ colors. gray } ;">
${ xpProgress. toLocaleString ()} / ${ xpNeeded. toLocaleString ()} XP
</span>
</div>
<div style="
width: 100%;
height: 24px;
background: ${ colors. lightGray } ;
border-radius: 12px;
overflow: hidden;
position: relative;
">
<div style="
width: ${ progressPercent} %;
height: 100%;
background: linear-gradient(90deg, ${ colors. teal } 0%, #1ABC9C 100%);
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 10px;
">
<span style="
color: white;
font-weight: bold;
font-size: 0.85em;
"> ${ Math . round (progressPercent)} %</span>
</div>
</div>
</div>
<p style="color: ${ colors. gray } ; margin: 0;">
You need ${ (xpNeeded - xpProgress). toLocaleString ()} more XP to reach Level ${ userData. level + 1 }
</p>
` ;
})()}
</div>
` : "" }
`
Show code
html `
${ userData && userData. chaptersCompleted > 0 ? html `
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 30px;
">
<h2 style="margin: 0 0 20px 0; color: ${ colors. navy } ;">📖 Recent Progress</h2>
<div style="display: flex; flex-direction: column; gap: 15px;">
${ 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 `
<div style="
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
background: ${ colors. lightGray } ;
border-radius: 8px;
">
<div style="
font-size: 2em;
flex-shrink: 0;
">✅</div>
<div style="flex: 1;">
<div style="font-weight: 500; color: ${ colors. navy } ; text-transform: capitalize;">
${ displayName}
</div>
<div style="font-size: 0.9em; color: ${ colors. gray } ;">
Completed ${ new Date (completedDate). toLocaleDateString ()}
</div>
</div>
<div style="
background: ${ colors. teal } ;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: bold;
">+100 XP</div>
</div>
` })}
</div>
</div>
` : "" }
`
Show code
html `
${ userData && userData. badgesEarned > 0 ? html `
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 30px;
">
<h2 style="margin: 0 0 20px 0; color: ${ colors. navy } ;">🏆 Your Badges</h2>
<div style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
">
${ userData. badges . map (badge => {
// Handle both old format and new Supabase format
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 ? html `<img src=" ${ badge. icon_url } " alt=" ${ badgeName} " style="width: 48px; height: 48px;">` : html `<span style="font-size: 3em;">🏆</span>` ;
return html `
<div style="
background: linear-gradient(135deg, ${ colors. lightGray } 0%, #E8EAED 100%);
padding: 20px;
border-radius: 12px;
text-align: center;
transition: transform 0.3s ease;
" onmouseover="this.style.transform='translateY(-4px)'"
onmouseout="this.style.transform='translateY(0)'">
<div style="margin-bottom: 10px;"> ${ badgeIcon} </div>
<div style="
font-weight: 500;
color: ${ colors. navy } ;
margin-bottom: 5px;
font-size: 0.95em;
"> ${ badgeName} </div>
<div style="
font-size: 0.8em;
color: ${ colors. gray } ;
">Earned ${ new Date (earnedDate). toLocaleDateString ()} </div>
</div>
` })}
</div>
</div>
` : "" }
`
Show code
html `
${ userData ? html `
<div style="
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
">
<h2 style="margin: 0 0 20px 0; color: ${ colors. navy } ;">🚀 Quick Actions</h2>
<div style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
">
<a href="../apps/content-hub/index.html" style="
display: block;
padding: 20px;
background: linear-gradient(135deg, ${ colors. teal } 0%, #138D75 100%);
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
transition: transform 0.3s ease;
" onmouseover="this.style.transform='translateY(-4px)'"
onmouseout="this.style.transform='translateY(0)'">
<div style="font-size: 2.5em; margin-bottom: 10px;">🎮</div>
<div style="font-weight: 500;">Play Games</div>
</a>
<a href="../apps/content-hub/index.html" style="
display: block;
padding: 20px;
background: linear-gradient(135deg, ${ colors. navy } 0%, ${ colors. darkGray } 100%);
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
transition: transform 0.3s ease;
" onmouseover="this.style.transform='translateY(-4px)'"
onmouseout="this.style.transform='translateY(0)'">
<div style="font-size: 2.5em; margin-bottom: 10px;">🔬</div>
<div style="font-weight: 500;">Run Simulations</div>
</a>
<a href="../apps/content-hub/index.html" style="
display: block;
padding: 20px;
background: linear-gradient(135deg, ${ colors. orange } 0%, #CA6F1E 100%);
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
transition: transform 0.3s ease;
" onmouseover="this.style.transform='translateY(-4px)'"
onmouseout="this.style.transform='translateY(0)'">
<div style="font-size: 2.5em; margin-bottom: 10px;">🧪</div>
<div style="font-weight: 500;">Try Labs</div>
</a>
<a href="/badges/showcase.html" style="
display: block;
padding: 20px;
background: linear-gradient(135deg, ${ colors. purple } 0%, #7D3C98 100%);
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
transition: transform 0.3s ease;
" onmouseover="this.style.transform='translateY(-4px)'"
onmouseout="this.style.transform='translateY(0)'">
<div style="font-size: 2.5em; margin-bottom: 10px;">👑</div>
<div style="font-weight: 500;">Badge Showcase</div>
</a>
<a href="/hubs/knowledge-gaps.html" style="
display: block;
padding: 20px;
background: linear-gradient(135deg, ${ colors. blue } 0%, #2980B9 100%);
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
transition: transform 0.3s ease;
" onmouseover="this.style.transform='translateY(-4px)'"
onmouseout="this.style.transform='translateY(0)'">
<div style="font-size: 2.5em; margin-bottom: 10px;">📊</div>
<div style="font-weight: 500;">Knowledge Gaps</div>
</a>
<a href="/hubs/learning-paths.html" style="
display: block;
padding: 20px;
background: linear-gradient(135deg, ${ colors. green } 0%, #1E8449 100%);
color: white;
text-decoration: none;
border-radius: 8px;
text-align: center;
transition: transform 0.3s ease;
" onmouseover="this.style.transform='translateY(-4px)'"
onmouseout="this.style.transform='translateY(0)'">
<div style="font-size: 2.5em; margin-bottom: 10px;">🛤️</div>
<div style="font-weight: 500;">Learning Paths</div>
</a>
</div>
</div>
` : "" }
`
Complete chapters : +100 XP each
Answer knowledge checks : +10 XP per correct answer
Complete Wokwi labs : +50 XP each
Score 90%+ on games : +25 XP
Complete capstone projects : +500 XP
7-day learning streak : +100 XP bonus
Back to top