feat: implement onboarding process and enrollment status tracking for users
This commit is contained in:
60
pb_migrations/1780900000_team_members_enrollment.js
Normal file
60
pb_migrations/1780900000_team_members_enrollment.js
Normal 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);
|
||||
});
|
||||
@@ -84,6 +84,8 @@ const COLLECTIONS = [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'pin', 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,
|
||||
],
|
||||
},
|
||||
|
||||
16
src/App.jsx
16
src/App.jsx
@@ -6,6 +6,7 @@ import BuildStamp from './components/ui/BuildStamp'
|
||||
import ChatLauncher from './components/chat/ChatLauncher'
|
||||
|
||||
import Login from './pages/Login'
|
||||
import Onboarding from './pages/Onboarding'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
|
||||
import Admin from './pages/Admin'
|
||||
@@ -14,11 +15,11 @@ import Testen from './pages/Testen'
|
||||
import Leaderboard from './pages/Leaderboard'
|
||||
|
||||
// Protected Route Wrapper
|
||||
const ProtectedRoute = ({ children, requireAdmin }) => {
|
||||
const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
|
||||
const { state, logout } = useApp()
|
||||
|
||||
|
||||
if (state.isLoading) return <div className="p-8">Loading...</div>
|
||||
|
||||
|
||||
if (!state.currentUser) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
@@ -27,6 +28,14 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
|
||||
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 (
|
||||
<div className="min-h-screen bg-bg text-fg font-sans flex flex-col pb-16 md:pb-0">
|
||||
{/* Top Navigation Bar */}
|
||||
@@ -92,6 +101,7 @@ function App() {
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
||||
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
||||
|
||||
@@ -3,17 +3,36 @@ import { callLLM, cachedSystem } from './llm';
|
||||
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) {
|
||||
return ((isoWeekNumber - 1) % 26) + 1;
|
||||
export function getPersonalWeekNumber(startedAt, now = new Date()) {
|
||||
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) {
|
||||
return Math.floor((isoWeekNumber - 1) / 26) + 1;
|
||||
export function getCurriculumWeek(weekNumber) {
|
||||
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();
|
||||
if (!activeVersion || !activeVersion.schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const weekNumber = getCurriculumWeek(isoWeekNumber);
|
||||
const cycle = getCurriculumCycle(isoWeekNumber);
|
||||
|
||||
const weekNumber = getCurriculumWeek(personalWeekNumber);
|
||||
const cycle = getCurriculumCycle(personalWeekNumber);
|
||||
if (weekNumber < 1) return null;
|
||||
|
||||
const scheduleWeek = activeVersion.schedule.find(w => w.week_number === weekNumber);
|
||||
if (!scheduleWeek) return null;
|
||||
@@ -317,13 +337,14 @@ export async function getCurrentWeekContent(isoWeekNumber) {
|
||||
/**
|
||||
* 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();
|
||||
if (!activeVersion) {
|
||||
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 cycleEndWeek = currentCycle * 26;
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ const Dashboard = () => {
|
||||
<div>
|
||||
<h3 className="text-xl">Learning</h3>
|
||||
<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>
|
||||
</div>
|
||||
{learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>}
|
||||
|
||||
74
src/pages/Onboarding.jsx
Normal file
74
src/pages/Onboarding.jsx
Normal 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;
|
||||
@@ -10,6 +10,7 @@ import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { generateWeeklyQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
||||
import { getCurriculumWeek, getCurriculumCycle } from '../lib/curriculumService';
|
||||
import { storage } from '../lib/storage';
|
||||
|
||||
const TIMER_SECONDS = 300; // 5 minutes
|
||||
@@ -180,7 +181,7 @@ const Testen = () => {
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
<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">
|
||||
Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
|
||||
</p>
|
||||
|
||||
@@ -1,40 +1,50 @@
|
||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import * as db from '../lib/db';
|
||||
import { pb } from '../lib/pb';
|
||||
import { getPersonalWeekNumber } from '../lib/curriculumService';
|
||||
|
||||
const AppContext = createContext();
|
||||
|
||||
const initialState = {
|
||||
currentUser: null,
|
||||
users: [],
|
||||
weekNumber: getWeekNumber(new Date()),
|
||||
weekNumber: 0,
|
||||
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) {
|
||||
switch (action.type) {
|
||||
case 'INIT_APP':
|
||||
return {
|
||||
...state,
|
||||
users: action.payload.users,
|
||||
weekNumber: action.payload.weekNumber || state.weekNumber,
|
||||
isLoading: false
|
||||
};
|
||||
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':
|
||||
return { ...state, currentUser: null };
|
||||
return { ...state, currentUser: null, weekNumber: 0 };
|
||||
default:
|
||||
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 }) {
|
||||
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');
|
||||
if (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);
|
||||
@@ -83,8 +91,23 @@ export function AppProvider({ children }) {
|
||||
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 (
|
||||
<AppContext.Provider value={{ state, dispatch, login, logout }}>
|
||||
<AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user