diff --git a/pb_migrations/1780900000_team_members_enrollment.js b/pb_migrations/1780900000_team_members_enrollment.js
new file mode 100644
index 0000000..adfd13b
--- /dev/null
+++ b/pb_migrations/1780900000_team_members_enrollment.js
@@ -0,0 +1,60 @@
+///
+// 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);
+});
diff --git a/scripts/setup-pb-collections.mjs b/scripts/setup-pb-collections.mjs
index 0055dac..e553daa 100644
--- a/scripts/setup-pb-collections.mjs
+++ b/scripts/setup-pb-collections.mjs
@@ -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,
],
},
diff --git a/src/App.jsx b/src/App.jsx
index 2c263d3..c8ee9a3 100644
--- a/src/App.jsx
+++ b/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
Loading...
-
+
if (!state.currentUser) {
return
}
@@ -27,6 +28,14 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
return
}
+ // 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
+ }
+
return (
{/* Top Navigation Bar */}
@@ -92,6 +101,7 @@ function App() {
<>
} />
+ } />
} />
} />
} />
diff --git a/src/lib/curriculumService.js b/src/lib/curriculumService.js
index 3444f1d..02c1387 100644
--- a/src/lib/curriculumService.js
+++ b/src/lib/curriculumService.js
@@ -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;
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index 1db4420..32aadfd 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -161,7 +161,7 @@ const Dashboard = () => {
Learning
- {curriculumActive ? `Week ${weekNumber} topic:` : 'Your topic this week:'}
+ {curriculumActive ? `Week ${currWeek} topic:` : 'Your topic this week:'}
{learnDone ? Completed : To Do}
diff --git a/src/pages/Onboarding.jsx b/src/pages/Onboarding.jsx
new file mode 100644
index 0000000..7ac628d
--- /dev/null
+++ b/src/pages/Onboarding.jsx
@@ -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 Loading...
;
+ if (!state.currentUser) return ;
+ if (state.currentUser.enrollment_status === 'active') {
+ return ;
+ }
+
+ 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 (
+
+
+
+

+
Welcome, {state.currentUser.name}
+
+
+
+
+
+
Start your learning journey
+
+ The curriculum is a 26-week cycle of weekly learning sessions and tests.
+
+
+ When you click Start, week 1 begins today. You can pick this up at any moment โ the cycle is yours.
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Onboarding;
diff --git a/src/pages/Testen.jsx b/src/pages/Testen.jsx
index dd06e73..94e8fb7 100644
--- a/src/pages/Testen.jsx
+++ b/src/pages/Testen.jsx
@@ -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 = () => {
Weekly Test
-
Week {weekNumber}
+
Cycle {getCurriculumCycle(weekNumber)} ยท Week {getCurriculumWeek(weekNumber)} of 26
Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
diff --git a/src/store/AppContext.jsx b/src/store/AppContext.jsx
index 4eeddd6..c2ce8ec 100644
--- a/src/store/AppContext.jsx
+++ b/src/store/AppContext.jsx
@@ -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 (
-
+
{children}
);