feat: implement onboarding process and enrollment status tracking for users

This commit is contained in:
RaymondVerhoef
2026-05-26 21:33:20 +02:00
parent febc9dc7f2
commit 7066f881f9
8 changed files with 225 additions and 34 deletions

View File

@@ -0,0 +1,60 @@
/// <reference path="../pb_data/types.d.ts" />
// Adds per-user curriculum enrollment fields to team_members:
// - curriculum_started_at: datetime the user started their 26-week cycle
// - enrollment_status: 'not_started' | 'active'
// Existing rows are reset to 'not_started' so every user re-enrolls via the
// onboarding screen on next login. All progress (micro-learning completions
// and leaderboard standings) is hard-reset so everyone restarts from week 1.
migrate((app) => {
const tm = app.findCollectionByNameOrId("team_members");
tm.fields.add(new Field({
"hidden": false,
"id": "date_curriculum_started_at",
"name": "curriculum_started_at",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}));
tm.fields.add(new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text_enrollment_status",
"max": 0,
"min": 0,
"name": "enrollment_status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}));
app.save(tm);
// Reset everyone to not_started — they re-enroll via the onboarding screen.
const members = app.findRecordsByFilter("team_members", "id != ''", "", 0, 0);
for (const rec of members) {
rec.set("enrollment_status", "not_started");
rec.set("curriculum_started_at", "");
app.save(rec);
}
// Hard-reset all progress so everyone genuinely restarts from week 1.
for (const name of ["micro_learning_completions", "leaderboard"]) {
const coll = app.findCollectionByNameOrId(name);
if (!coll) continue;
const rows = app.findRecordsByFilter(name, "id != ''", "", 0, 0);
for (const row of rows) {
app.delete(row);
}
}
}, (app) => {
const tm = app.findCollectionByNameOrId("team_members");
tm.fields.removeById("date_curriculum_started_at");
tm.fields.removeById("text_enrollment_status");
app.save(tm);
});

View File

@@ -84,6 +84,8 @@ const COLLECTIONS = [
{ name: 'name', type: 'text', required: true }, { name: 'name', type: 'text', required: true },
{ name: 'pin', type: 'text', required: false }, { name: 'pin', type: 'text', required: false },
{ name: 'role', type: 'text', required: false }, { name: 'role', type: 'text', required: false },
{ name: 'curriculum_started_at', type: 'date', required: false },
{ name: 'enrollment_status', type: 'text', required: false },
...AUTODATE_FIELDS, ...AUTODATE_FIELDS,
], ],
}, },

View File

@@ -6,6 +6,7 @@ import BuildStamp from './components/ui/BuildStamp'
import ChatLauncher from './components/chat/ChatLauncher' import ChatLauncher from './components/chat/ChatLauncher'
import Login from './pages/Login' import Login from './pages/Login'
import Onboarding from './pages/Onboarding'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Admin from './pages/Admin' import Admin from './pages/Admin'
@@ -14,7 +15,7 @@ import Testen from './pages/Testen'
import Leaderboard from './pages/Leaderboard' import Leaderboard from './pages/Leaderboard'
// Protected Route Wrapper // Protected Route Wrapper
const ProtectedRoute = ({ children, requireAdmin }) => { const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
const { state, logout } = useApp() const { state, logout } = useApp()
if (state.isLoading) return <div className="p-8">Loading...</div> if (state.isLoading) return <div className="p-8">Loading...</div>
@@ -27,6 +28,14 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
return <Navigate to="/" replace /> return <Navigate to="/" replace />
} }
// Block any non-enrolled user from reaching curriculum pages.
// Admins are exempt from the gate when they're heading to the admin panel.
const needsEnrollment = state.currentUser.enrollment_status !== 'active'
const isAdmin = state.currentUser.role === 'admin'
if (needsEnrollment && !skipEnrollmentGate && !(requireAdmin && isAdmin)) {
return <Navigate to="/onboarding" replace />
}
return ( return (
<div className="min-h-screen bg-bg text-fg font-sans flex flex-col pb-16 md:pb-0"> <div className="min-h-screen bg-bg text-fg font-sans flex flex-col pb-16 md:pb-0">
{/* Top Navigation Bar */} {/* Top Navigation Bar */}
@@ -92,6 +101,7 @@ function App() {
<> <>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} /> <Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} /> <Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />

View File

@@ -3,17 +3,36 @@ import { callLLM, cachedSystem } from './llm';
import { EMIT_CURRICULUM_SCHEDULE_TOOL, EMIT_TOPIC_ENRICHMENT_TOOL } from './llmTools'; import { EMIT_CURRICULUM_SCHEDULE_TOOL, EMIT_TOPIC_ENRICHMENT_TOOL } from './llmTools';
/** /**
* Get the current curriculum week (1-26) based on an ISO week number. * Personal weeks since enrollment, starting at 1.
* Cycle is purely relative: week 1 = first 7 days after start_date.
* Returns 0 if the user has not yet enrolled.
*/ */
export function getCurriculumWeek(isoWeekNumber) { export function getPersonalWeekNumber(startedAt, now = new Date()) {
return ((isoWeekNumber - 1) % 26) + 1; if (!startedAt) return 0;
const start = startedAt instanceof Date ? startedAt : new Date(startedAt);
if (Number.isNaN(start.getTime())) return 0;
const elapsedMs = now.getTime() - start.getTime();
if (elapsedMs < 0) return 0;
const days = Math.floor(elapsedMs / 86_400_000);
return Math.floor(days / 7) + 1;
} }
/** /**
* Get the current curriculum cycle (1, 2, 3...) based on an ISO week number. * Curriculum slot (1-26) for a given personal week number.
* weekNumber is the absolute counter from getPersonalWeekNumber — it loops
* through the 26-slot schedule indefinitely.
*/ */
export function getCurriculumCycle(isoWeekNumber) { export function getCurriculumWeek(weekNumber) {
return Math.floor((isoWeekNumber - 1) / 26) + 1; if (!weekNumber || weekNumber < 1) return 0;
return ((weekNumber - 1) % 26) + 1;
}
/**
* Cycle (1, 2, 3...) for a given personal week number.
*/
export function getCurriculumCycle(weekNumber) {
if (!weekNumber || weekNumber < 1) return 0;
return Math.floor((weekNumber - 1) / 26) + 1;
} }
/** /**
@@ -284,16 +303,17 @@ export async function getVersionHistory() {
} }
/** /**
* Get the assigned topics and metadata for a given ISO week number. * Get the assigned topics and metadata for a given personal week number.
*/ */
export async function getCurrentWeekContent(isoWeekNumber) { export async function getCurrentWeekContent(personalWeekNumber) {
const activeVersion = await db.getActiveCurriculumVersion(); const activeVersion = await db.getActiveCurriculumVersion();
if (!activeVersion || !activeVersion.schedule) { if (!activeVersion || !activeVersion.schedule) {
return null; return null;
} }
const weekNumber = getCurriculumWeek(isoWeekNumber); const weekNumber = getCurriculumWeek(personalWeekNumber);
const cycle = getCurriculumCycle(isoWeekNumber); const cycle = getCurriculumCycle(personalWeekNumber);
if (weekNumber < 1) return null;
const scheduleWeek = activeVersion.schedule.find(w => w.week_number === weekNumber); const scheduleWeek = activeVersion.schedule.find(w => w.week_number === weekNumber);
if (!scheduleWeek) return null; if (!scheduleWeek) return null;
@@ -317,13 +337,14 @@ export async function getCurrentWeekContent(isoWeekNumber) {
/** /**
* Track progress for the current cycle based on completed weeks. * Track progress for the current cycle based on completed weeks.
*/ */
export async function getYearProgress(userId, isoWeekNumber) { export async function getYearProgress(userId, personalWeekNumber) {
const activeVersion = await db.getActiveCurriculumVersion(); const activeVersion = await db.getActiveCurriculumVersion();
if (!activeVersion) { if (!activeVersion) {
return { completed: 0, total: 26, percentage: 0 }; return { completed: 0, total: 26, percentage: 0 };
} }
const currentCycle = getCurriculumCycle(isoWeekNumber); const currentCycle = getCurriculumCycle(personalWeekNumber);
if (currentCycle < 1) return { completed: 0, total: 26, percentage: 0 };
const cycleStartWeek = (currentCycle - 1) * 26 + 1; const cycleStartWeek = (currentCycle - 1) * 26 + 1;
const cycleEndWeek = currentCycle * 26; const cycleEndWeek = currentCycle * 26;

View File

@@ -161,7 +161,7 @@ const Dashboard = () => {
<div> <div>
<h3 className="text-xl">Learning</h3> <h3 className="text-xl">Learning</h3>
<p className="text-fg-muted text-sm mt-1"> <p className="text-fg-muted text-sm mt-1">
{curriculumActive ? `Week ${weekNumber} topic:` : 'Your topic this week:'} {curriculumActive ? `Week ${currWeek} topic:` : 'Your topic this week:'}
</p> </p>
</div> </div>
{learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>} {learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>}

74
src/pages/Onboarding.jsx Normal file
View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useNavigate, Navigate } from 'react-router-dom';
import { BookOpen, ArrowRight, LogOut } from 'lucide-react';
import { useApp } from '../store/AppContext';
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
const Onboarding = () => {
const { state, enrollCurrentUser, logout } = useApp();
const navigate = useNavigate();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
if (state.isLoading) return <div className="p-8">Loading...</div>;
if (!state.currentUser) return <Navigate to="/login" replace />;
if (state.currentUser.enrollment_status === 'active') {
return <Navigate to="/" replace />;
}
const handleStart = async () => {
setBusy(true);
setError('');
try {
await enrollCurrentUser();
navigate('/', { replace: true });
} catch (e) {
setError(e.message || 'Could not start the curriculum. Please try again.');
setBusy(false);
}
};
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<div className="w-full max-w-lg">
<div className="text-center mb-8">
<img src="/images/icon.png" alt="Respellion Icon" className="h-20 mx-auto mb-4" />
<h1 className="text-3xl text-teal font-bold tracking-tight">Welcome, {state.currentUser.name}</h1>
</div>
<Card className="border border-bg-warm">
<div className="text-center py-2">
<BookOpen size={48} className="mx-auto text-teal mb-4" />
<h2 className="text-2xl font-bold mb-3">Start your learning journey</h2>
<p className="text-fg-muted mb-2">
The curriculum is a 26-week cycle of weekly learning sessions and tests.
</p>
<p className="text-fg-muted mb-6">
When you click <strong>Start</strong>, week 1 begins today. You can pick this up at any moment the cycle is yours.
</p>
{error && (
<div className="text-red-500 text-sm font-medium mb-4">{error}</div>
)}
<Button onClick={handleStart} disabled={busy} className="text-lg px-8 py-3">
{busy ? 'Starting...' : <>Start my journey <ArrowRight size={20} className="ml-2" /></>}
</Button>
<div className="mt-6">
<button
onClick={logout}
className="text-xs text-fg-muted hover:text-fg inline-flex items-center gap-1"
>
<LogOut size={12} /> Sign out
</button>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Onboarding;

View File

@@ -10,6 +10,7 @@ import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag'; import Tag from '../components/ui/Tag';
import { useApp } from '../store/AppContext'; import { useApp } from '../store/AppContext';
import { generateWeeklyQuiz, saveTestResult, getTestResult } from '../lib/testService'; import { generateWeeklyQuiz, saveTestResult, getTestResult } from '../lib/testService';
import { getCurriculumWeek, getCurriculumCycle } from '../lib/curriculumService';
import { storage } from '../lib/storage'; import { storage } from '../lib/storage';
const TIMER_SECONDS = 300; // 5 minutes const TIMER_SECONDS = 300; // 5 minutes
@@ -180,7 +181,7 @@ const Testen = () => {
<div className="text-center py-12"> <div className="text-center py-12">
<CheckSquare size={64} className="mx-auto text-teal/30 mb-6" /> <CheckSquare size={64} className="mx-auto text-teal/30 mb-6" />
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1> <h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1>
<p className="text-fg-muted text-lg mb-2">Week {weekNumber}</p> <p className="text-fg-muted text-lg mb-2">Cycle {getCurriculumCycle(weekNumber)} · Week {getCurriculumWeek(weekNumber)} of 26</p>
<p className="text-fg-muted mb-8 max-w-md mx-auto"> <p className="text-fg-muted mb-8 max-w-md mx-auto">
Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck! Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
</p> </p>

View File

@@ -1,40 +1,50 @@
import { createContext, useContext, useReducer, useEffect } from 'react'; import { createContext, useContext, useReducer, useEffect } from 'react';
import * as db from '../lib/db'; import * as db from '../lib/db';
import { pb } from '../lib/pb';
import { getPersonalWeekNumber } from '../lib/curriculumService';
const AppContext = createContext(); const AppContext = createContext();
const initialState = { const initialState = {
currentUser: null, currentUser: null,
users: [], users: [],
weekNumber: getWeekNumber(new Date()), weekNumber: 0,
isLoading: true isLoading: true
}; };
function computeWeekNumber(user) {
if (!user || !user.curriculum_started_at || user.enrollment_status !== 'active') return 0;
return getPersonalWeekNumber(user.curriculum_started_at);
}
function appReducer(state, action) { function appReducer(state, action) {
switch (action.type) { switch (action.type) {
case 'INIT_APP': case 'INIT_APP':
return { return {
...state, ...state,
users: action.payload.users, users: action.payload.users,
weekNumber: action.payload.weekNumber || state.weekNumber,
isLoading: false isLoading: false
}; };
case 'LOGIN': case 'LOGIN':
return { ...state, currentUser: action.payload }; return {
...state,
currentUser: action.payload,
weekNumber: computeWeekNumber(action.payload),
};
case 'UPDATE_CURRENT_USER':
return {
...state,
currentUser: action.payload,
weekNumber: computeWeekNumber(action.payload),
users: state.users.map(u => u.id === action.payload.id ? action.payload : u),
};
case 'LOGOUT': case 'LOGOUT':
return { ...state, currentUser: null }; return { ...state, currentUser: null, weekNumber: 0 };
default: default:
return state; return state;
} }
} }
function getWeekNumber(d) {
d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay()||7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
return Math.ceil(( ( (d - yearStart) / 86400000) + 1)/7);
}
export function AppProvider({ children }) { export function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState); const [state, dispatch] = useReducer(appReducer, initialState);
@@ -52,8 +62,6 @@ export function AppProvider({ children }) {
} }
} }
const storedWeek = getWeekNumber(new Date());
const sessionUserId = sessionStorage.getItem('respellion_session'); const sessionUserId = sessionStorage.getItem('respellion_session');
if (sessionUserId) { if (sessionUserId) {
const user = members.find(u => u.id === sessionUserId); const user = members.find(u => u.id === sessionUserId);
@@ -62,7 +70,7 @@ export function AppProvider({ children }) {
} }
} }
dispatch({ type: 'INIT_APP', payload: { users: members, weekNumber: storedWeek } }); dispatch({ type: 'INIT_APP', payload: { users: members } });
}; };
loadState().catch(console.error); loadState().catch(console.error);
@@ -83,8 +91,23 @@ export function AppProvider({ children }) {
dispatch({ type: 'LOGOUT' }); dispatch({ type: 'LOGOUT' });
}; };
/**
* Start the curriculum for the currently logged-in user.
* Records the start timestamp and flips enrollment_status to 'active'.
*/
const enrollCurrentUser = async () => {
if (!state.currentUser) throw new Error('No user is logged in.');
const startedAt = new Date().toISOString();
const updated = await pb.collection('team_members').update(state.currentUser.id, {
curriculum_started_at: startedAt,
enrollment_status: 'active',
});
dispatch({ type: 'UPDATE_CURRENT_USER', payload: updated });
return updated;
};
return ( return (
<AppContext.Provider value={{ state, dispatch, login, logout }}> <AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
{children} {children}
</AppContext.Provider> </AppContext.Provider>
); );