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 ( +
+
+
+ Respellion Icon +

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} );