feat: implement 52-week annual curriculum system with admin management and automated topic progression
This commit is contained in:
18
AI_AGENT.md
18
AI_AGENT.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Welcome, fellow AI agent! If you are reading this, you are tasked with maintaining or extending the Respellion Learning Platform. This document provides the critical context, architectural patterns, and design standards you need to successfully work on this codebase.
|
Welcome, fellow AI agent! If you are reading this, you are tasked with maintaining or extending the Respellion Learning Platform. This document provides the critical context, architectural patterns, and design standards you need to successfully work on this codebase.
|
||||||
|
|
||||||
> **Last updated:** 2026-05-17 — Reflects selective content generation (3 types), ISO week alignment, GitHub sync folder change, and AI extraction token limits.
|
> **Last updated:** 2026-05-18 — Adds 52-week annual curriculum system (§12). Reflects selective content generation (3 types), ISO week alignment, GitHub sync folder change, and AI extraction token limits.
|
||||||
|
|
||||||
## 1. Architectural Overview
|
## 1. Architectural Overview
|
||||||
This is a single-page React application built with **Vite**, backed by **PocketBase** as the database and auth layer.
|
This is a single-page React application built with **Vite**, backed by **PocketBase** as the database and auth layer.
|
||||||
@@ -26,6 +26,7 @@ All persistent data lives in **PocketBase**. The data access layer is in `src/li
|
|||||||
* `leaderboard` — Points ledger (`user_id`, `name`, `points`, `tests_completed`).
|
* `leaderboard` — Points ledger (`user_id`, `name`, `points`, `tests_completed`).
|
||||||
* `team_members` — Registered users with PIN auth (`name`, `role`, `pin`).
|
* `team_members` — Registered users with PIN auth (`name`, `role`, `pin`).
|
||||||
* `sources` — Uploaded source documents and their extraction status (`name`, `status`, `error`).
|
* `sources` — Uploaded source documents and their extraction status (`name`, `status`, `error`).
|
||||||
|
* `curriculum` — Annual learning schedule (`year`, `week_number`, `topic_id`, `theme`, `quarter`, `is_review_week`, `sort_order`). One entry per week per year. Managed via the admin Curriculum tab.
|
||||||
* `settings` — Key/value store for app-wide settings (`key`, `value`).
|
* `settings` — Key/value store for app-wide settings (`key`, `value`).
|
||||||
|
|
||||||
**localStorage** is only used for **admin browser settings** (not user data):
|
**localStorage** is only used for **admin browser settings** (not user data):
|
||||||
@@ -40,6 +41,8 @@ All persistent data lives in **PocketBase**. The data access layer is in `src/li
|
|||||||
|
|
||||||
**Week Number:** The current ISO-8601 week number is calculated dynamically on app load via `getWeekNumber(new Date())` in `src/store/AppContext.jsx`. It is **not** stored in the database. The `ADVANCE_WEEK` action still exists for admin use, but initial state always reflects the real calendar week.
|
**Week Number:** The current ISO-8601 week number is calculated dynamically on app load via `getWeekNumber(new Date())` in `src/store/AppContext.jsx`. It is **not** stored in the database. The `ADVANCE_WEEK` action still exists for admin use, but initial state always reflects the real calendar week.
|
||||||
|
|
||||||
|
**Curriculum Year:** The curriculum year is derived from `new Date().getFullYear()` via `getCurriculumYear()` in `src/lib/curriculumService.js`. It is not stored — always computed.
|
||||||
|
|
||||||
**Important:** All `db.js` functions are `async`. Always `await` them — omitting `await` will silently pass a Promise where data is expected.
|
**Important:** All `db.js` functions are `async`. Always `await` them — omitting `await` will silently pass a Promise where data is expected.
|
||||||
|
|
||||||
**Auto-Cancellation:** The PocketBase JS SDK has auto-cancellation enabled by default. This causes concurrent identical requests (like `db.getTopics()` during React StrictMode renders or concurrent Promise.all) to abort with `ClientResponseError 0`. This feature is **globally disabled** in `src/lib/pb.js` via `pb.autoCancellation(false)` to prevent UI crashes during concurrent fetching.
|
**Auto-Cancellation:** The PocketBase JS SDK has auto-cancellation enabled by default. This causes concurrent identical requests (like `db.getTopics()` during React StrictMode renders or concurrent Promise.all) to abort with `ClientResponseError 0`. This feature is **globally disabled** in `src/lib/pb.js` via `pb.autoCancellation(false)` to prevent UI crashes during concurrent fetching.
|
||||||
@@ -128,4 +131,17 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe
|
|||||||
* **AI token budget.** If you see `[Pipeline] AI returned non-JSON response` in the logs, the response was truncated. Increase the topic cap prompt constraint before raising `max_tokens`.
|
* **AI token budget.** If you see `[Pipeline] AI returned non-JSON response` in the logs, the response was truncated. Increase the topic cap prompt constraint before raising `max_tokens`.
|
||||||
* **PocketBase auto-cancellation is OFF.** `pb.autoCancellation(false)` is set globally in `src/lib/pb.js`. Never re-enable it — it causes abort errors during concurrent fetches in React StrictMode.
|
* **PocketBase auto-cancellation is OFF.** `pb.autoCancellation(false)` is set globally in `src/lib/pb.js`. Never re-enable it — it causes abort errors during concurrent fetches in React StrictMode.
|
||||||
|
|
||||||
|
## 12. Annual Curriculum System
|
||||||
|
The platform uses a **52-week annual curriculum** so every employee covers all knowledge-base topics in one calendar year.
|
||||||
|
|
||||||
|
* **Service:** `src/lib/curriculumService.js` — curriculum engine with topic lookup, progress tracking, and auto-generation.
|
||||||
|
* **DB functions:** `db.getCurriculum(year)`, `db.getCurriculumWeek(year, week)`, `db.setCurriculumWeek(...)`, `db.bulkSetCurriculum(year, weeks[])`.
|
||||||
|
* **Admin UI:** `src/components/admin/CurriculumManager.jsx` — accessed via the "Curriculum" tab in the admin panel. Admins can auto-generate a schedule from KB topics or manually assign topics per week.
|
||||||
|
* **Same topic for everyone:** All employees study the same topic each week — this is by design to enable team discussion and shared quizzes.
|
||||||
|
* **Quarterly structure:** Weeks 1–13 (Q1), 14–26 (Q2), 27–39 (Q3), 40–52 (Q4). Review/recap weeks at 13, 26, 39, 52.
|
||||||
|
* **Fallback:** If no curriculum exists for the current year, `getAssignedTopic()` falls back to the legacy hash-based assignment for backward compatibility.
|
||||||
|
* **Progress tracking:** `getYearProgress(userId)` and `getQuarterProgress(userId, quarter)` compute completion from the `learn_progress` collection against the curriculum.
|
||||||
|
* **Auto-generate:** `autoGenerateCurriculum(year)` distributes all non-fact topics across 48 content weeks + 4 review weeks. If there are fewer topics than weeks, they cycle. If more, excess topics remain in the self-service library.
|
||||||
|
* **Do not remove the hash fallback** — it ensures the platform works even without a configured curriculum.
|
||||||
|
|
||||||
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.
|
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.
|
||||||
|
|||||||
@@ -140,6 +140,21 @@ const COLLECTIONS = [
|
|||||||
...AUTODATE_FIELDS,
|
...AUTODATE_FIELDS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'curriculum',
|
||||||
|
type: 'base',
|
||||||
|
...OPEN_RULES,
|
||||||
|
fields: [
|
||||||
|
{ name: 'year', type: 'number', required: true },
|
||||||
|
{ name: 'week_number', type: 'number', required: true },
|
||||||
|
{ name: 'topic_id', type: 'text', required: false },
|
||||||
|
{ name: 'theme', type: 'text', required: false },
|
||||||
|
{ name: 'quarter', type: 'number', required: false },
|
||||||
|
{ name: 'is_review_week', type: 'bool', required: false },
|
||||||
|
{ name: 'sort_order', type: 'number', required: false },
|
||||||
|
...AUTODATE_FIELDS,
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
type: 'base',
|
type: 'base',
|
||||||
|
|||||||
321
src/components/admin/CurriculumManager.jsx
Normal file
321
src/components/admin/CurriculumManager.jsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, BookOpen, Loader, AlertTriangle } from 'lucide-react';
|
||||||
|
import Card from '../ui/Card';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import Tag from '../ui/Tag';
|
||||||
|
import * as db from '../../lib/db';
|
||||||
|
import {
|
||||||
|
autoGenerateCurriculum,
|
||||||
|
getCurriculumYear,
|
||||||
|
getQuarterForWeek,
|
||||||
|
getQuarterName,
|
||||||
|
getFullCurriculum,
|
||||||
|
hasCurriculum,
|
||||||
|
} from '../../lib/curriculumService';
|
||||||
|
|
||||||
|
const QUARTER_COLORS = {
|
||||||
|
1: { bg: 'bg-teal-50', border: 'border-teal-200', text: 'text-teal-700', accent: 'var(--color-teal)' },
|
||||||
|
2: { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', accent: '#7c3aed' },
|
||||||
|
3: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', accent: '#2563eb' },
|
||||||
|
4: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', accent: '#d97706' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CurriculumManager = () => {
|
||||||
|
const [year, setYear] = useState(getCurriculumYear());
|
||||||
|
const [curriculum, setCurriculum] = useState([]);
|
||||||
|
const [topics, setTopics] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [expandedQuarters, setExpandedQuarters] = useState({ 1: true, 2: true, 3: true, 4: true });
|
||||||
|
const [editingWeek, setEditingWeek] = useState(null);
|
||||||
|
const [saveStatus, setSaveStatus] = useState(null);
|
||||||
|
|
||||||
|
const currentWeek = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const [currData, topicData] = await Promise.all([
|
||||||
|
getFullCurriculum(year),
|
||||||
|
db.getTopics(),
|
||||||
|
]);
|
||||||
|
setCurriculum(currData);
|
||||||
|
setTopics(topicData.filter(t => t.type !== 'fact'));
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [year]);
|
||||||
|
|
||||||
|
const handleAutoGenerate = async () => {
|
||||||
|
if (curriculum.length > 0 && !confirm('This will replace the existing curriculum for ' + year + '. Continue?')) return;
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
await autoGenerateCurriculum(year);
|
||||||
|
await load();
|
||||||
|
setSaveStatus('Curriculum generated!');
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to generate curriculum:', e);
|
||||||
|
setSaveStatus('Generation failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWeekTopicChange = async (weekNumber, topicId) => {
|
||||||
|
const topic = topics.find(t => t.id === topicId);
|
||||||
|
await db.setCurriculumWeek(year, weekNumber, {
|
||||||
|
topic_id: topicId,
|
||||||
|
theme: topic?.type || 'General',
|
||||||
|
quarter: getQuarterForWeek(weekNumber),
|
||||||
|
is_review_week: false,
|
||||||
|
sort_order: weekNumber,
|
||||||
|
});
|
||||||
|
setEditingWeek(null);
|
||||||
|
await load();
|
||||||
|
setSaveStatus('Week ' + weekNumber + ' updated');
|
||||||
|
setTimeout(() => setSaveStatus(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleReview = async (weekNumber, currentEntry) => {
|
||||||
|
await db.setCurriculumWeek(year, weekNumber, {
|
||||||
|
topic_id: currentEntry?.topic_id || '',
|
||||||
|
theme: !currentEntry?.is_review_week ? `Q${getQuarterForWeek(weekNumber)} Review` : currentEntry?.theme || '',
|
||||||
|
quarter: getQuarterForWeek(weekNumber),
|
||||||
|
is_review_week: !currentEntry?.is_review_week,
|
||||||
|
sort_order: weekNumber,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleQuarter = (q) => {
|
||||||
|
setExpandedQuarters(prev => ({ ...prev, [q]: !prev[q] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group by quarter
|
||||||
|
const quarters = [1, 2, 3, 4].map(q => ({
|
||||||
|
quarter: q,
|
||||||
|
name: getQuarterName(q * 13 - 12),
|
||||||
|
weeks: curriculum.filter(w => w.quarter === q),
|
||||||
|
colors: QUARTER_COLORS[q],
|
||||||
|
startWeek: (q - 1) * 13 + 1,
|
||||||
|
endWeek: q * 13,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const assignedCount = curriculum.filter(w => w.topic_id).length;
|
||||||
|
const reviewCount = curriculum.filter(w => w.is_review_week).length;
|
||||||
|
const unassignedCount = curriculum.length > 0 ? 52 - assignedCount - reviewCount : 52;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader size={32} className="text-teal animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with year selector and stats */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Calendar size={20} className="text-teal" />
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={e => setYear(Number(e.target.value))}
|
||||||
|
className="text-lg font-bold bg-transparent border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 focus:outline-none focus:border-teal transition-colors"
|
||||||
|
>
|
||||||
|
{[year - 1, year, year + 1].map(y => (
|
||||||
|
<option key={y} value={y}>{y}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{saveStatus && <span className="text-teal text-sm font-medium">{saveStatus}</span>}
|
||||||
|
<Button
|
||||||
|
onClick={handleAutoGenerate}
|
||||||
|
variant={curriculum.length > 0 ? 'outline' : 'primary'}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<><Loader size={16} className="mr-2 animate-spin" /> Generating...</>
|
||||||
|
) : (
|
||||||
|
<><Wand2 size={16} className="mr-2" /> {curriculum.length > 0 ? 'Regenerate' : 'Auto-Generate'} Curriculum</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats bar */}
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-teal">{curriculum.length}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Total Weeks</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: '#22c55e' }}>{assignedCount}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Topics Assigned</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: '#7c3aed' }}>{reviewCount}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Review Weeks</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: unassignedCount > 0 ? '#ef4444' : '#22c55e' }}>{unassignedCount}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Unassigned</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{curriculum.length > 0 && (
|
||||||
|
<div className="mt-4 h-2 rounded-full bg-bg-warm overflow-hidden flex">
|
||||||
|
{[1, 2, 3, 4].map(q => {
|
||||||
|
const qWeeks = curriculum.filter(w => w.quarter === q).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q}
|
||||||
|
style={{ width: `${(qWeeks / 52) * 100}%`, backgroundColor: QUARTER_COLORS[q].accent }}
|
||||||
|
className="h-full transition-all"
|
||||||
|
title={`Q${q}: ${qWeeks} weeks`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{curriculum.length === 0 && (
|
||||||
|
<Card className="border border-bg-warm text-center py-12">
|
||||||
|
<Calendar size={48} className="mx-auto text-fg-muted/30 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">No curriculum for {year}</h3>
|
||||||
|
<p className="text-fg-muted mb-6 max-w-md mx-auto">
|
||||||
|
Click "Auto-Generate Curriculum" to distribute all knowledge base topics across 52 weeks
|
||||||
|
with quarterly review periods.
|
||||||
|
</p>
|
||||||
|
{topics.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-amber-600 text-sm mb-4">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<span>No topics in the knowledge base yet. Import sources first.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quarter sections */}
|
||||||
|
{curriculum.length > 0 && quarters.map(({ quarter, name, weeks, colors, startWeek, endWeek }) => (
|
||||||
|
<div key={quarter} className="space-y-0">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleQuarter(quarter)}
|
||||||
|
className={`w-full flex items-center justify-between p-4 rounded-t-[var(--r-lg)] border ${colors.border} ${colors.bg} transition-colors hover:opacity-90`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{expandedQuarters[quarter] ? <ChevronDown size={20} className={colors.text} /> : <ChevronRight size={20} className={colors.text} />}
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className={`font-bold ${colors.text}`}>Q{quarter}: {name}</h3>
|
||||||
|
<p className="text-xs text-fg-muted">Weeks {startWeek}–{endWeek} · {weeks.filter(w => w.topic_id).length} topics assigned</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tag variant="dark" className="text-xs">{weeks.length} weeks</Tag>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedQuarters[quarter] && (
|
||||||
|
<Card className={`rounded-t-none border ${colors.border} border-t-0 p-0 overflow-hidden`}>
|
||||||
|
<div className="divide-y divide-bg-warm">
|
||||||
|
{/* Fill in all weeks for the quarter, even if not in curriculum */}
|
||||||
|
{Array.from({ length: 13 }, (_, i) => startWeek + i).map(weekNum => {
|
||||||
|
const entry = weeks.find(w => w.week_number === weekNum) || curriculum.find(w => w.week_number === weekNum);
|
||||||
|
const isCurrent = weekNum === currentWeek && year === getCurriculumYear();
|
||||||
|
const isPast = year < getCurriculumYear() || (year === getCurriculumYear() && weekNum < currentWeek);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={weekNum}
|
||||||
|
className={`flex items-center gap-4 p-3 px-4 transition-colors ${
|
||||||
|
isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : ''
|
||||||
|
} ${isPast ? 'opacity-60' : ''} hover:bg-bg-warm/30`}
|
||||||
|
>
|
||||||
|
{/* Week number */}
|
||||||
|
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-mono text-sm font-bold flex-shrink-0 ${
|
||||||
|
isCurrent ? 'bg-teal text-white' :
|
||||||
|
entry?.is_review_week ? 'bg-purple-100 text-purple-700' :
|
||||||
|
entry?.topic_id ? 'bg-bg-warm text-fg' :
|
||||||
|
'bg-bg-warm/50 text-fg-muted'
|
||||||
|
}`}>
|
||||||
|
{weekNum}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{entry?.is_review_week ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RotateCcw size={14} className="text-purple-600" />
|
||||||
|
<span className="font-medium text-purple-700">{entry.theme || `Q${quarter} Review`}</span>
|
||||||
|
</div>
|
||||||
|
) : entry?.topic ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{entry.topic.label}</span>
|
||||||
|
<span className="text-xs text-fg-muted ml-2">{entry.theme}</span>
|
||||||
|
</div>
|
||||||
|
) : entry?.topic_id ? (
|
||||||
|
<span className="text-fg-muted italic">Topic: {entry.topic_id} (not found)</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-fg-muted">Unassigned</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{isCurrent && <Tag variant="accent" className="text-[10px]">Current</Tag>}
|
||||||
|
{isPast && entry?.topic_id && <CheckCircle2 size={16} className="text-teal/50" />}
|
||||||
|
|
||||||
|
{editingWeek === weekNum ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
autoFocus
|
||||||
|
defaultValue={entry?.topic_id || ''}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value === '__review__') {
|
||||||
|
handleToggleReview(weekNum, entry);
|
||||||
|
setEditingWeek(null);
|
||||||
|
} else {
|
||||||
|
handleWeekTopicChange(weekNum, e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setEditingWeek(null)}
|
||||||
|
className="text-sm border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 bg-bg focus:outline-none focus:border-teal max-w-[200px]"
|
||||||
|
>
|
||||||
|
<option value="">— Unassigned —</option>
|
||||||
|
<option value="__review__">📋 Review Week</option>
|
||||||
|
{topics.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingWeek(weekNum)}
|
||||||
|
className="text-xs text-fg-muted hover:text-teal transition-colors px-2 py-1 rounded hover:bg-bg-warm"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CurriculumManager;
|
||||||
253
src/lib/curriculumService.js
Normal file
253
src/lib/curriculumService.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import * as db from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default quarterly theme structure for auto-generating a curriculum.
|
||||||
|
* Each quarter has a name and a list of thematic blocks.
|
||||||
|
*/
|
||||||
|
const DEFAULT_QUARTERS = [
|
||||||
|
{
|
||||||
|
quarter: 1,
|
||||||
|
name: 'Foundation & Governance',
|
||||||
|
themes: [
|
||||||
|
'Company Purpose & Values',
|
||||||
|
'Governance',
|
||||||
|
'People & Culture',
|
||||||
|
'Recruitment',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quarter: 2,
|
||||||
|
name: 'Compliance, Legal & Finance',
|
||||||
|
themes: [
|
||||||
|
'Privacy',
|
||||||
|
'Compliance',
|
||||||
|
'Quality',
|
||||||
|
'Finance',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quarter: 3,
|
||||||
|
name: 'Technology & Operations',
|
||||||
|
themes: [
|
||||||
|
'Strategy',
|
||||||
|
'Infrastructure',
|
||||||
|
'Workplace',
|
||||||
|
'Service Management',
|
||||||
|
'Software Delivery',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quarter: 4,
|
||||||
|
name: 'Business, Marketing & Sustainability',
|
||||||
|
themes: [
|
||||||
|
'Marketing',
|
||||||
|
'Networking',
|
||||||
|
'Events',
|
||||||
|
'Sustainability',
|
||||||
|
'Year Wrap-up',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current curriculum year based on a date.
|
||||||
|
* Uses the calendar year.
|
||||||
|
*/
|
||||||
|
export function getCurriculumYear(date = new Date()) {
|
||||||
|
return date.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the quarter number (1-4) for a given ISO week number.
|
||||||
|
*/
|
||||||
|
export function getQuarterForWeek(weekNumber) {
|
||||||
|
if (weekNumber <= 13) return 1;
|
||||||
|
if (weekNumber <= 26) return 2;
|
||||||
|
if (weekNumber <= 39) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the quarter name for a given week number.
|
||||||
|
*/
|
||||||
|
export function getQuarterName(weekNumber) {
|
||||||
|
const q = getQuarterForWeek(weekNumber);
|
||||||
|
return DEFAULT_QUARTERS[q - 1]?.name || `Quarter ${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned topic for a given week from the curriculum.
|
||||||
|
* Returns { topic, curriculumEntry } or { topic: null } if no curriculum exists.
|
||||||
|
*/
|
||||||
|
export async function getCurriculumTopic(weekNumber, year) {
|
||||||
|
const currYear = year ?? getCurriculumYear();
|
||||||
|
const entry = await db.getCurriculumWeek(currYear, weekNumber);
|
||||||
|
|
||||||
|
if (!entry || !entry.topic_id) {
|
||||||
|
return { topic: null, curriculumEntry: entry || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the topic from the topics collection
|
||||||
|
const topics = await db.getTopics();
|
||||||
|
const topic = topics.find(t => t.id === entry.topic_id) || null;
|
||||||
|
|
||||||
|
return { topic, curriculumEntry: entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full curriculum for a year, with resolved topic labels.
|
||||||
|
*/
|
||||||
|
export async function getFullCurriculum(year) {
|
||||||
|
const currYear = year ?? getCurriculumYear();
|
||||||
|
const entries = await db.getCurriculum(currYear);
|
||||||
|
const topics = await db.getTopics();
|
||||||
|
const topicMap = Object.fromEntries(topics.map(t => [t.id, t]));
|
||||||
|
|
||||||
|
return entries.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
topic: topicMap[entry.topic_id] || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get progress for a user in a given quarter.
|
||||||
|
* Returns { completed, total, percentage }.
|
||||||
|
*/
|
||||||
|
export async function getQuarterProgress(userId, quarter, year) {
|
||||||
|
const currYear = year ?? getCurriculumYear();
|
||||||
|
const curriculum = await db.getCurriculum(currYear);
|
||||||
|
const quarterWeeks = curriculum.filter(w => w.quarter === quarter);
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
for (const week of quarterWeeks) {
|
||||||
|
const done = await db.getLearnDone(userId, week.week_number);
|
||||||
|
if (done) completed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed,
|
||||||
|
total: quarterWeeks.length,
|
||||||
|
percentage: quarterWeeks.length > 0
|
||||||
|
? Math.round((completed / quarterWeeks.length) * 100)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overall annual progress for a user.
|
||||||
|
* Returns { completed, total, percentage }.
|
||||||
|
*/
|
||||||
|
export async function getYearProgress(userId, year) {
|
||||||
|
const currYear = year ?? getCurriculumYear();
|
||||||
|
const curriculum = await db.getCurriculum(currYear);
|
||||||
|
|
||||||
|
if (curriculum.length === 0) {
|
||||||
|
return { completed: 0, total: 52, percentage: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
for (const week of curriculum) {
|
||||||
|
const done = await db.getLearnDone(userId, week.week_number);
|
||||||
|
if (done) completed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed,
|
||||||
|
total: curriculum.length,
|
||||||
|
percentage: Math.round((completed / curriculum.length) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming weeks from the curriculum (next N weeks after current).
|
||||||
|
*/
|
||||||
|
export async function getUpcomingWeeks(currentWeek, count = 4, year) {
|
||||||
|
const currYear = year ?? getCurriculumYear();
|
||||||
|
const curriculum = await getFullCurriculum(currYear);
|
||||||
|
|
||||||
|
return curriculum
|
||||||
|
.filter(w => w.week_number > currentWeek && w.week_number <= currentWeek + count)
|
||||||
|
.sort((a, b) => a.week_number - b.week_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generate a 52-week curriculum from available topics.
|
||||||
|
* Distributes topics evenly across quarters, adds review weeks at 13, 26, 39, 52.
|
||||||
|
*/
|
||||||
|
export async function autoGenerateCurriculum(year) {
|
||||||
|
const currYear = year ?? getCurriculumYear();
|
||||||
|
const topics = await db.getTopics();
|
||||||
|
|
||||||
|
// Filter out 'fact' type topics — those are for the knowledge graph only
|
||||||
|
const learningTopics = topics.filter(t => t.type !== 'fact');
|
||||||
|
|
||||||
|
const weeks = [];
|
||||||
|
const reviewWeeks = [13, 26, 39, 52];
|
||||||
|
|
||||||
|
// Calculate available weeks (52 total minus review weeks)
|
||||||
|
const availableWeeks = 52 - reviewWeeks.length; // 48
|
||||||
|
|
||||||
|
// Distribute topics across available weeks
|
||||||
|
let topicIndex = 0;
|
||||||
|
|
||||||
|
for (let w = 1; w <= 52; w++) {
|
||||||
|
const quarter = getQuarterForWeek(w);
|
||||||
|
|
||||||
|
if (reviewWeeks.includes(w)) {
|
||||||
|
// Review / recap week
|
||||||
|
weeks.push({
|
||||||
|
week_number: w,
|
||||||
|
topic_id: '',
|
||||||
|
theme: `Q${quarter} Review`,
|
||||||
|
quarter,
|
||||||
|
is_review_week: true,
|
||||||
|
sort_order: w,
|
||||||
|
});
|
||||||
|
} else if (topicIndex < learningTopics.length) {
|
||||||
|
const topic = learningTopics[topicIndex];
|
||||||
|
weeks.push({
|
||||||
|
week_number: w,
|
||||||
|
topic_id: topic.id,
|
||||||
|
theme: topic.type || 'General',
|
||||||
|
quarter,
|
||||||
|
is_review_week: false,
|
||||||
|
sort_order: w,
|
||||||
|
});
|
||||||
|
topicIndex++;
|
||||||
|
} else if (learningTopics.length > 0) {
|
||||||
|
// If we have more weeks than topics, cycle through topics again
|
||||||
|
const topic = learningTopics[topicIndex % learningTopics.length];
|
||||||
|
weeks.push({
|
||||||
|
week_number: w,
|
||||||
|
topic_id: topic.id,
|
||||||
|
theme: `${topic.type || 'General'} (Deep Dive)`,
|
||||||
|
quarter,
|
||||||
|
is_review_week: false,
|
||||||
|
sort_order: w,
|
||||||
|
});
|
||||||
|
topicIndex++;
|
||||||
|
} else {
|
||||||
|
// No topics at all
|
||||||
|
weeks.push({
|
||||||
|
week_number: w,
|
||||||
|
topic_id: '',
|
||||||
|
theme: 'Unassigned',
|
||||||
|
quarter,
|
||||||
|
is_review_week: false,
|
||||||
|
sort_order: w,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.bulkSetCurriculum(currYear, weeks);
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a curriculum exists for the given year.
|
||||||
|
*/
|
||||||
|
export async function hasCurriculum(year) {
|
||||||
|
const currYear = year ?? getCurriculumYear();
|
||||||
|
const entries = await db.getCurriculum(currYear);
|
||||||
|
return entries.length > 0;
|
||||||
|
}
|
||||||
@@ -244,3 +244,67 @@ export async function setSetting(key, value) {
|
|||||||
return pb.collection('settings').create({ key, value: String(value) });
|
return pb.collection('settings').create({ key, value: String(value) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Curriculum ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getCurriculum(year) {
|
||||||
|
try {
|
||||||
|
return await pb.collection('curriculum').getFullList({
|
||||||
|
filter: `year=${year}`,
|
||||||
|
sort: 'week_number',
|
||||||
|
});
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurriculumWeek(year, weekNumber) {
|
||||||
|
try {
|
||||||
|
return await pb.collection('curriculum').getFirstListItem(
|
||||||
|
`year=${year} && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCurriculumWeek(year, weekNumber, data) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('curriculum').getFirstListItem(
|
||||||
|
`year=${year} && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
return pb.collection('curriculum').update(r.id, data);
|
||||||
|
} catch {
|
||||||
|
return pb.collection('curriculum').create({
|
||||||
|
year,
|
||||||
|
week_number: weekNumber,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCurriculumWeek(year, weekNumber) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('curriculum').getFirstListItem(
|
||||||
|
`year=${year} && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
return pb.collection('curriculum').delete(r.id);
|
||||||
|
} catch { /* nothing to delete */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkSetCurriculum(year, weeks) {
|
||||||
|
// Delete all existing entries for this year first
|
||||||
|
const existing = await getCurriculum(year);
|
||||||
|
await Promise.all(
|
||||||
|
existing.map(r => pb.collection('curriculum').delete(r.id, { requestKey: null }))
|
||||||
|
);
|
||||||
|
// Create all new entries
|
||||||
|
return Promise.all(
|
||||||
|
weeks.map(w => pb.collection('curriculum').create({
|
||||||
|
year,
|
||||||
|
week_number: w.week_number,
|
||||||
|
topic_id: w.topic_id || '',
|
||||||
|
theme: w.theme || '',
|
||||||
|
quarter: w.quarter || Math.ceil(w.week_number / 13),
|
||||||
|
is_review_week: w.is_review_week || false,
|
||||||
|
sort_order: w.sort_order ?? w.week_number,
|
||||||
|
}, { requestKey: null }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { anthropicApi } from './api';
|
import { anthropicApi } from './api';
|
||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
|
import { getCurriculumTopic, getCurriculumYear } from './curriculumService';
|
||||||
|
|
||||||
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
||||||
You write training material for employees based on knowledge topics.
|
You write training material for employees based on knowledge topics.
|
||||||
@@ -46,7 +47,21 @@ const CONTENT_SCHEMA_ALL = `{
|
|||||||
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
|
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned topic for a given week.
|
||||||
|
* Curriculum-first: checks the curriculum collection for the current year.
|
||||||
|
* Falls back to hash-based assignment if no curriculum is configured.
|
||||||
|
*/
|
||||||
export async function getAssignedTopic(userId, weekNumber) {
|
export async function getAssignedTopic(userId, weekNumber) {
|
||||||
|
// Try curriculum first
|
||||||
|
try {
|
||||||
|
const { topic } = await getCurriculumTopic(weekNumber);
|
||||||
|
if (topic) return topic;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: hash-based assignment (backwards compatible)
|
||||||
const topics = await db.getTopics();
|
const topics = await db.getTopics();
|
||||||
if (!topics || topics.length === 0) return null;
|
if (!topics || topics.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { anthropicApi } from './api';
|
import { anthropicApi } from './api';
|
||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
|
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
|
||||||
|
|
||||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||||
You generate multiple-choice questions to test employee knowledge on specific topics.
|
You generate multiple-choice questions to test employee knowledge on specific topics.
|
||||||
@@ -8,8 +9,39 @@ ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
|||||||
|
|
||||||
async function selectTestTopics(userId, weekNumber) {
|
async function selectTestTopics(userId, weekNumber) {
|
||||||
const topics = await db.getTopics();
|
const topics = await db.getTopics();
|
||||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [] };
|
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||||
|
|
||||||
|
// Try curriculum-based selection first
|
||||||
|
try {
|
||||||
|
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
||||||
|
|
||||||
|
if (curriculumEntry?.is_review_week) {
|
||||||
|
// Review week: pull topics from the whole quarter
|
||||||
|
const quarter = getQuarterForWeek(weekNumber);
|
||||||
|
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
||||||
|
const quarterTopicIds = curriculum
|
||||||
|
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
|
||||||
|
.map(w => w.topic_id);
|
||||||
|
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
|
||||||
|
// Use all quarter topics as review topics (no single primary)
|
||||||
|
return {
|
||||||
|
primaryTopic: quarterTopics[0] || topics[0],
|
||||||
|
reviewTopics: quarterTopics.slice(1),
|
||||||
|
isReviewWeek: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic) {
|
||||||
|
const others = topics.filter(t => t.id !== topic.id);
|
||||||
|
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||||
|
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||||
|
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: hash-based selection
|
||||||
const str = `${userId}:${weekNumber}`;
|
const str = `${userId}:${weekNumber}`;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
@@ -23,7 +55,7 @@ async function selectTestTopics(userId, weekNumber) {
|
|||||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||||
|
|
||||||
return { primaryTopic, reviewTopics };
|
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedQuiz(userId, weekNumber) {
|
export async function getCachedQuiz(userId, weekNumber) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare } from 'lucide-react';
|
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays } from 'lucide-react';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Tag from '../../components/ui/Tag';
|
import Tag from '../../components/ui/Tag';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
@@ -11,6 +11,7 @@ import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
|
|||||||
import ContentManager from '../../components/admin/ContentManager';
|
import ContentManager from '../../components/admin/ContentManager';
|
||||||
import TestManager from '../../components/admin/TestManager';
|
import TestManager from '../../components/admin/TestManager';
|
||||||
import TeamManager from '../../components/admin/TeamManager';
|
import TeamManager from '../../components/admin/TeamManager';
|
||||||
|
import CurriculumManager from '../../components/admin/CurriculumManager';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
const Admin = () => {
|
const Admin = () => {
|
||||||
@@ -54,6 +55,7 @@ const Admin = () => {
|
|||||||
{ key: 'sources', icon: Database, label: 'Sources' },
|
{ key: 'sources', icon: Database, label: 'Sources' },
|
||||||
{ key: 'content', icon: Layers, label: 'Content' },
|
{ key: 'content', icon: Layers, label: 'Content' },
|
||||||
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
|
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
|
||||||
|
{ key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
|
||||||
{ key: 'graph', icon: Network, label: 'Graph' },
|
{ key: 'graph', icon: Network, label: 'Graph' },
|
||||||
{ key: 'team', icon: Users, label: 'Team' },
|
{ key: 'team', icon: Users, label: 'Team' },
|
||||||
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true },
|
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true },
|
||||||
@@ -140,6 +142,14 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'curriculum' && (
|
||||||
|
<div className="animate-in fade-in duration-300 max-w-5xl mx-auto">
|
||||||
|
<h1 className="text-3xl text-teal mb-2">Annual Curriculum</h1>
|
||||||
|
<p className="text-fg-muted mb-8">Plan and manage the 52-week learning schedule. All employees follow the same weekly topic.</p>
|
||||||
|
<CurriculumManager />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'graph' && (
|
{activeTab === 'graph' && (
|
||||||
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
||||||
<h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
|
<h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Button from '../components/ui/Button';
|
|||||||
import Tag from '../components/ui/Tag';
|
import Tag from '../components/ui/Tag';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
import { getAssignedTopic } from '../lib/learningService';
|
import { getAssignedTopic } from '../lib/learningService';
|
||||||
|
import { getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
@@ -19,6 +20,8 @@ const Dashboard = () => {
|
|||||||
myRank: 0,
|
myRank: 0,
|
||||||
myPoints: 0,
|
myPoints: 0,
|
||||||
activity: [],
|
activity: [],
|
||||||
|
yearProgress: null,
|
||||||
|
hasCurriculum: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,27 +53,103 @@ const Dashboard = () => {
|
|||||||
if (pastLearn) activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
|
if (pastLearn) activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
|
||||||
}
|
}
|
||||||
|
|
||||||
setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity });
|
// Load curriculum progress
|
||||||
|
let yearProgress = null;
|
||||||
|
let curriculumExists = false;
|
||||||
|
try {
|
||||||
|
curriculumExists = await checkHasCurriculum();
|
||||||
|
if (curriculumExists) {
|
||||||
|
yearProgress = await getYearProgress(currentUser.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumExists });
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [currentUser, weekNumber]);
|
}, [currentUser, weekNumber]);
|
||||||
|
|
||||||
const { topic, learnDone, testResult, top3, myRank, myPoints, activity } = dashData;
|
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive } = dashData;
|
||||||
|
const currentQuarter = getQuarterForWeek(weekNumber);
|
||||||
|
const quarterName = getQuarterName(weekNumber);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
|
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
|
||||||
<p className="text-fg-muted text-lg">Here is your overview for week {weekNumber}.</p>
|
<p className="text-fg-muted text-lg">
|
||||||
|
{curriculumActive
|
||||||
|
? `Week ${weekNumber} · ${quarterName}`
|
||||||
|
: `Here is your overview for week ${weekNumber}.`}
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Annual Progress Bar (only when curriculum exists) */}
|
||||||
|
{curriculumActive && yearProgress && (
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative w-14 h-14 flex-shrink-0">
|
||||||
|
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||||
|
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
|
||||||
|
<circle
|
||||||
|
cx="18" cy="18" r="15.5" fill="none"
|
||||||
|
stroke="var(--color-teal)" strokeWidth="3"
|
||||||
|
strokeDasharray={`${yearProgress.percentage} 100`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="transition-all duration-700"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold">
|
||||||
|
{yearProgress.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg">Annual Progress</h3>
|
||||||
|
<p className="text-sm text-fg-muted">{yearProgress.completed} of {yearProgress.total} weeks completed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Tag variant="dark" className="text-xs">Q{currentQuarter}</Tag>
|
||||||
|
<span className="text-sm text-fg-muted">{52 - weekNumber} weeks remaining</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Visual week progress bar */}
|
||||||
|
<div className="mt-4 flex gap-[2px] h-2 rounded-full overflow-hidden">
|
||||||
|
{Array.from({ length: 52 }, (_, i) => {
|
||||||
|
const w = i + 1;
|
||||||
|
const isCurrent = w === weekNumber;
|
||||||
|
const isPast = w < weekNumber;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={w}
|
||||||
|
className="flex-1 rounded-sm transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isCurrent
|
||||||
|
? 'var(--color-teal)'
|
||||||
|
: isPast
|
||||||
|
? 'var(--color-teal)'
|
||||||
|
: 'var(--color-bg-warm)',
|
||||||
|
opacity: isPast ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
title={`Week ${w}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card className="flex flex-col border border-bg-warm" hoverable>
|
<Card className="flex flex-col border border-bg-warm" hoverable>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl">Learning</h3>
|
<h3 className="text-xl">Learning</h3>
|
||||||
<p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
|
<p className="text-fg-muted text-sm mt-1">
|
||||||
|
{curriculumActive ? `Week ${weekNumber} topic:` : 'Your topic this week:'}
|
||||||
|
</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>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare } from 'lucide-react';
|
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
@@ -9,6 +9,7 @@ import Input from '../components/ui/Input';
|
|||||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
|
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
|
||||||
|
import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
|
|
||||||
const Leren = () => {
|
const Leren = () => {
|
||||||
@@ -37,6 +38,12 @@ const Leren = () => {
|
|||||||
const [feedbackText, setFeedbackText] = useState('');
|
const [feedbackText, setFeedbackText] = useState('');
|
||||||
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
|
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
|
||||||
|
|
||||||
|
// Curriculum state
|
||||||
|
const [hasCurriculum, setHasCurriculum] = useState(false);
|
||||||
|
const [upcoming, setUpcoming] = useState([]);
|
||||||
|
const [quarterProgress, setQuarterProgress] = useState(null);
|
||||||
|
const [yearProgress, setYearProgress] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.currentUser) {
|
if (state.currentUser) {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -48,6 +55,24 @@ const Leren = () => {
|
|||||||
setAssignedTopic(assigned);
|
setAssignedTopic(assigned);
|
||||||
setAllTopics(topics);
|
setAllTopics(topics);
|
||||||
if (done) setWeeklyDone(true);
|
if (done) setWeeklyDone(true);
|
||||||
|
|
||||||
|
// Load curriculum data
|
||||||
|
try {
|
||||||
|
const currExists = await checkHasCurriculum();
|
||||||
|
setHasCurriculum(currExists);
|
||||||
|
if (currExists) {
|
||||||
|
const [upcomingData, qProgress, yProgress] = await Promise.all([
|
||||||
|
getUpcomingWeeks(state.weekNumber, 4),
|
||||||
|
getQuarterProgress(state.currentUser.id, getQuarterForWeek(state.weekNumber)),
|
||||||
|
getYearProgress(state.currentUser.id),
|
||||||
|
]);
|
||||||
|
setUpcoming(upcomingData);
|
||||||
|
setQuarterProgress(qProgress);
|
||||||
|
setYearProgress(yProgress);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Learn] Could not load curriculum data:', e.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
@@ -271,13 +296,17 @@ const Leren = () => {
|
|||||||
|
|
||||||
// ── Overview ──────────────────────────────────────────────
|
// ── Overview ──────────────────────────────────────────────
|
||||||
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact');
|
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact');
|
||||||
|
const currentQuarter = getQuarterForWeek(state.weekNumber);
|
||||||
|
const currentQuarterName = getQuarterName(state.weekNumber);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
|
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
|
||||||
<p className="text-fg-muted text-lg">
|
<p className="text-fg-muted text-lg">
|
||||||
You must complete at least 1 topic per week. Feel free to explore more from the library!
|
{hasCurriculum
|
||||||
|
? `Week ${state.weekNumber} · ${currentQuarterName}`
|
||||||
|
: 'Complete at least 1 topic per week. Explore more from the library!'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,11 +316,74 @@ const Leren = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Progress Cards (only shown when curriculum exists) */}
|
||||||
|
{hasCurriculum && yearProgress && quarterProgress && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
{/* Year Progress */}
|
||||||
|
<Card className="border border-bg-warm text-center p-4">
|
||||||
|
<div className="relative w-16 h-16 mx-auto mb-2">
|
||||||
|
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||||
|
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
|
||||||
|
<circle
|
||||||
|
cx="18" cy="18" r="15.5" fill="none"
|
||||||
|
stroke="var(--color-teal)" strokeWidth="3"
|
||||||
|
strokeDasharray={`${yearProgress.percentage} 100`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="transition-all duration-700"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
|
||||||
|
{yearProgress.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-fg-muted">Annual Progress</div>
|
||||||
|
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quarter Progress */}
|
||||||
|
<Card className="border border-bg-warm text-center p-4">
|
||||||
|
<div className="relative w-16 h-16 mx-auto mb-2">
|
||||||
|
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||||
|
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
|
||||||
|
<circle
|
||||||
|
cx="18" cy="18" r="15.5" fill="none"
|
||||||
|
stroke="#7c3aed" strokeWidth="3"
|
||||||
|
strokeDasharray={`${quarterProgress.percentage} 100`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="transition-all duration-700"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
|
||||||
|
{quarterProgress.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-fg-muted">Q{currentQuarter} Progress</div>
|
||||||
|
<div className="text-[10px] text-fg-muted">{quarterProgress.completed}/{quarterProgress.total} weeks</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Current Week */}
|
||||||
|
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
||||||
|
<Calendar size={24} className="text-teal mb-1" />
|
||||||
|
<div className="text-2xl font-bold text-teal">{state.weekNumber}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Current Week</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
||||||
|
<TrendingUp size={24} className={`mb-1 ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`} />
|
||||||
|
<div className={`text-lg font-bold ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`}>
|
||||||
|
{weeklyDone ? 'Complete' : 'In Progress'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-fg-muted">This Week</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Required Topic */}
|
{/* Required Topic */}
|
||||||
{assignedTopic && (
|
{assignedTopic && (
|
||||||
<div className="mb-12">
|
<div className="mb-8">
|
||||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
Weekly Assignment {weeklyDone && <CheckCircle size={20} className="text-teal" />}
|
This Week's Topic {weeklyDone && <CheckCircle size={20} className="text-teal" />}
|
||||||
</h2>
|
</h2>
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
@@ -300,9 +392,14 @@ const Leren = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Tag variant={weeklyDone ? 'success' : 'accent'} className="mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{weeklyDone ? 'Completed' : 'Required'}
|
<Tag variant={weeklyDone ? 'success' : 'accent'} className="text-xs">
|
||||||
</Tag>
|
{weeklyDone ? 'Completed' : 'Required'}
|
||||||
|
</Tag>
|
||||||
|
{hasCurriculum && (
|
||||||
|
<Tag variant="dark" className="text-[10px]">Week {state.weekNumber}</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
|
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
|
||||||
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
|
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +411,34 @@ const Leren = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming Schedule (only when curriculum exists) */}
|
||||||
|
{hasCurriculum && upcoming.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<Calendar size={20} className="text-fg-muted" /> Coming Up
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{upcoming.map(week => (
|
||||||
|
<Card key={week.week_number} className="border border-bg-warm p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Tag variant="dark" className="text-[10px] font-mono">Week {week.week_number}</Tag>
|
||||||
|
{week.is_review_week && <Tag variant="accent" className="text-[10px]">Review</Tag>}
|
||||||
|
</div>
|
||||||
|
{week.topic ? (
|
||||||
|
<>
|
||||||
|
<h4 className="font-medium text-sm leading-tight">{week.topic.label}</h4>
|
||||||
|
<p className="text-xs text-fg-muted mt-1">{week.theme}</p>
|
||||||
|
</>
|
||||||
|
) : week.is_review_week ? (
|
||||||
|
<h4 className="font-medium text-sm text-purple-600">{week.theme}</h4>
|
||||||
|
) : (
|
||||||
|
<h4 className="text-sm text-fg-muted italic">Unassigned</h4>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Other Available Topics */}
|
{/* Other Available Topics */}
|
||||||
{otherTopics.length > 0 && (
|
{otherTopics.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user