feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration

This commit is contained in:
RaymondVerhoef
2026-05-14 16:53:10 +02:00
parent 42d7209773
commit 74ba5d3dc0
15 changed files with 590 additions and 512 deletions

View File

@@ -5,6 +5,17 @@ services:
expose: expose:
- "80" - "80"
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
restart: always
expose:
- "8090"
ports:
- "8090:8090" # Remove after initial setup via admin UI
volumes:
- pb_data:/pb/pb_data
command: ["serve", "--http=0.0.0.0:8090"]
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
restart: always restart: always
@@ -17,21 +28,25 @@ services:
- /bin/sh - /bin/sh
- -c - -c
- | - |
# Build Caddyfile on container start
CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';') CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';')
cat > /etc/caddy/Caddyfile <<EOF cat > /etc/caddy/Caddyfile <<EOF
$${CLEAN_DOMAIN} { $${CLEAN_DOMAIN} {
handle /pb/* {
uri strip_prefix /pb
reverse_proxy pocketbase:8090
}
reverse_proxy /* frontend:80 reverse_proxy /* frontend:80
} }
EOF EOF
# Run Caddy with the generated file
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
volumes: volumes:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
depends_on: depends_on:
- frontend - frontend
- pocketbase
volumes: volumes:
caddy_data: caddy_data:
caddy_config: caddy_config:
pb_data:

8
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"d3": "^7.9.0", "d3": "^7.9.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"pocketbase": "^0.26.9",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.15.0" "react-router-dom": "^7.15.0"
@@ -2348,7 +2349,6 @@
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
@@ -2899,6 +2899,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pocketbase": {
"version": "0.26.9",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.9.tgz",
"integrity": "sha512-Tiv1/hNuUzRdvT0d8hF03dfzuefQ1WdSRp1A1q3wzFC/WYhQcbU/Qlaubl/3ZDo6xvFXBS8JAgBS/L+ms7nkVQ==",
"license": "MIT"
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.14", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",

View File

@@ -13,6 +13,7 @@
"d3": "^7.9.0", "d3": "^7.9.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"pocketbase": "^0.26.9",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.15.0" "react-router-dom": "^7.15.0"

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react'; import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
import { storage } from '../../lib/storage'; import * as db from '../../lib/db';
import { anthropicApi } from '../../lib/api'; import { anthropicApi } from '../../lib/api';
import Button from '../ui/Button'; import Button from '../ui/Button';
@@ -16,13 +16,12 @@ const KnowledgeGraph = () => {
const [analyzeError, setAnalyzeError] = useState(null); const [analyzeError, setAnalyzeError] = useState(null);
const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' }); const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' });
// Load data into state so it can update
const [topics, setTopics] = useState([]); const [topics, setTopics] = useState([]);
const [relations, setRelations] = useState([]); const [relations, setRelations] = useState([]);
useEffect(() => { useEffect(() => {
setTopics(storage.get('kb:topics', [])); db.getTopics().then(setTopics);
setRelations(storage.get('kb:relations', [])); db.getRelations().then(setRelations);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -58,9 +57,7 @@ const KnowledgeGraph = () => {
const zoom = d3.zoom() const zoom = d3.zoom()
.scaleExtent([0.1, 4]) .scaleExtent([0.1, 4])
.on('zoom', (event) => { .on('zoom', (event) => { g.attr('transform', event.transform); });
g.attr('transform', event.transform);
});
svg.call(zoom); svg.call(zoom);
@@ -141,52 +138,49 @@ const KnowledgeGraph = () => {
setIsEditing(true); setIsEditing(true);
}; };
const saveEdit = () => { const saveEdit = async () => {
const updatedTopics = topics.map(t => await db.upsertTopic({ ...selectedNode, ...editData });
t.id === selectedNode.id ? { ...t, ...editData } : t const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...editData } : t);
); setTopics(updated);
storage.set('kb:topics', updatedTopics);
setTopics(updatedTopics);
setSelectedNode({ ...selectedNode, ...editData }); setSelectedNode({ ...selectedNode, ...editData });
setIsEditing(false); setIsEditing(false);
}; };
const deleteNode = () => { const deleteNode = async () => {
if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) { if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) {
const updatedTopics = topics.filter(t => t.id !== selectedNode.id); const updatedTopics = topics.filter(t => t.id !== selectedNode.id);
const updatedRelations = relations.filter(r => r.source !== selectedNode.id && r.target !== selectedNode.id); const updatedRelations = relations.filter(r => r.source !== selectedNode.id && r.target !== selectedNode.id);
storage.set('kb:topics', updatedTopics); await db.saveTopics(updatedTopics);
storage.set('kb:relations', updatedRelations); await db.saveRelations(updatedRelations);
await db.deleteContent(selectedNode.id);
await db.setQuizBank(selectedNode.id, []);
setTopics(updatedTopics); setTopics(updatedTopics);
setRelations(updatedRelations); setRelations(updatedRelations);
setSelectedNode(null); setSelectedNode(null);
setIsEditing(false); setIsEditing(false);
// Clear associated content to keep it clean
storage.remove(`kb:content:${selectedNode.id}`);
storage.remove(`quiz:bank:${selectedNode.id}`);
} }
}; };
const addRelation = () => { const addRelation = async () => {
if (!newRelData.target) return; if (!newRelData.target) return;
const rawRelations = storage.get('kb:relations', []); const isDup = relations.some(r => (r.source.id || r.source) === selectedNode.id && (r.target.id || r.target) === newRelData.target && r.type === newRelData.type);
const isDup = rawRelations.some(r => r.source === selectedNode.id && r.target === newRelData.target && r.type === newRelData.type);
if (isDup) return; if (isDup) return;
const updated = [...rawRelations, { source: selectedNode.id, target: newRelData.target, type: newRelData.type }]; const newRel = { source: selectedNode.id, target: newRelData.target, type: newRelData.type };
storage.set('kb:relations', updated); await db.addRelation(newRel);
setRelations(updated); setRelations([...relations, newRel]);
setNewRelData({ target: '', type: 'related_to' }); setNewRelData({ target: '', type: 'related_to' });
}; };
const removeRelation = (sourceId, targetId, type) => { const removeRelation = async (sourceId, targetId, type) => {
const rawRelations = storage.get('kb:relations', []); await db.removeRelation(sourceId, targetId, type);
const updated = rawRelations.filter(r => !(r.source === sourceId && r.target === targetId && r.type === type)); setRelations(relations.filter(r => !(
storage.set('kb:relations', updated); (r.source.id || r.source) === sourceId &&
setRelations(updated); (r.target.id || r.target) === targetId &&
r.type === type
)));
}; };
const analyzeGraph = async () => { const analyzeGraph = async () => {
@@ -196,8 +190,8 @@ const KnowledgeGraph = () => {
setAnalyzeError(null); setAnalyzeError(null);
try { try {
const currentTopics = storage.get('kb:topics', []); const currentTopics = await db.getTopics();
const currentRelations = storage.get('kb:relations', []); const currentRelations = await db.getRelations();
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph. const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph.
Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add. Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add.
@@ -227,11 +221,9 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
let updatedTopics = [...currentTopics]; let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations]; let updatedRelations = [...currentRelations];
// Process Merges
if (actions.merges && Array.isArray(actions.merges)) { if (actions.merges && Array.isArray(actions.merges)) {
for (const merge of actions.merges) { for (const merge of actions.merges) {
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId); updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
// Redirect relations
updatedRelations = updatedRelations.map(r => { updatedRelations = updatedRelations.map(r => {
if (r.source === merge.deleteId) return { ...r, source: merge.keepId }; if (r.source === merge.deleteId) return { ...r, source: merge.keepId };
if (r.target === merge.deleteId) return { ...r, target: merge.keepId }; if (r.target === merge.deleteId) return { ...r, target: merge.keepId };
@@ -240,28 +232,22 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
} }
} }
// Process Deletions
if (actions.deletions && Array.isArray(actions.deletions)) { if (actions.deletions && Array.isArray(actions.deletions)) {
updatedTopics = updatedTopics.filter(t => !actions.deletions.includes(t.id)); updatedTopics = updatedTopics.filter(t => !actions.deletions.includes(t.id));
updatedRelations = updatedRelations.filter(r => !actions.deletions.includes(r.source) && !actions.deletions.includes(r.target)); updatedRelations = updatedRelations.filter(r => !actions.deletions.includes(r.source) && !actions.deletions.includes(r.target));
} }
// Process New Relations
if (actions.newRelations && Array.isArray(actions.newRelations)) { if (actions.newRelations && Array.isArray(actions.newRelations)) {
for (const newRel of actions.newRelations) { for (const newRel of actions.newRelations) {
// Prevent exact duplicates
const isDup = updatedRelations.some(r => r.source === newRel.source && r.target === newRel.target && r.type === newRel.type); const isDup = updatedRelations.some(r => r.source === newRel.source && r.target === newRel.target && r.type === newRel.type);
if (!isDup) { if (!isDup) updatedRelations.push(newRel);
updatedRelations.push(newRel);
}
} }
} }
// Final cleanup to remove self-referencing relations that might occur after merges
updatedRelations = updatedRelations.filter(r => r.source !== r.target); updatedRelations = updatedRelations.filter(r => r.source !== r.target);
storage.set('kb:topics', updatedTopics); await db.saveTopics(updatedTopics);
storage.set('kb:relations', updatedRelations); await db.saveRelations(updatedRelations);
setTopics(updatedTopics); setTopics(updatedTopics);
setRelations(updatedRelations); setRelations(updatedRelations);
@@ -286,9 +272,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
)} )}
</div> </div>
{/* Node Info Panel */}
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col"> <div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col">
{/* Global Action */}
<div className="mb-6 pb-4 border-b border-bg-warm"> <div className="mb-6 pb-4 border-b border-bg-warm">
<Button <Button
onClick={analyzeGraph} onClick={analyzeGraph}
@@ -364,9 +348,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
</div> </div>
<div> <div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p> <p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono"> <span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">{selectedNode.type}</span>
{selectedNode.type}
</span>
</div> </div>
<div> <div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p> <p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p>
@@ -380,7 +362,6 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
<div className="pt-4 border-t border-bg-warm"> <div className="pt-4 border-t border-bg-warm">
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p> <p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p>
{/* Existing Relations where selectedNode is source */}
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
{relations.filter(r => (r.source.id || r.source) === selectedNode.id).map((rel, idx) => { {relations.filter(r => (r.source.id || r.source) === selectedNode.id).map((rel, idx) => {
const targetId = rel.target.id || rel.target; const targetId = rel.target.id || rel.target;
@@ -402,7 +383,6 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
})} })}
</div> </div>
{/* Add New Relation */}
<div className="bg-bg rounded-[var(--r-sm)] p-3 border border-bg-warm space-y-2"> <div className="bg-bg rounded-[var(--r-sm)] p-3 border border-bg-warm space-y-2">
<p className="text-xs font-medium flex items-center gap-1"><LinkIcon size={12}/> Add Relation</p> <p className="text-xs font-medium flex items-center gap-1"><LinkIcon size={12}/> Add Relation</p>
<select <select

View File

@@ -4,7 +4,8 @@ import Card from '../ui/Card';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Input from '../ui/Input'; import Input from '../ui/Input';
import Tag from '../ui/Tag'; import Tag from '../ui/Tag';
import { storage } from '../../lib/storage'; import * as db from '../../lib/db';
import { pb } from '../../lib/pb';
import { useApp } from '../../store/AppContext'; import { useApp } from '../../store/AppContext';
const TeamManager = () => { const TeamManager = () => {
@@ -12,44 +13,32 @@ const TeamManager = () => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' }); const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const loadUsers = () => { const loadUsers = async () => {
setUsers(storage.get('users:registry', [])); const members = await db.getTeamMembers();
setUsers(members);
}; };
useEffect(() => { loadUsers(); }, []); useEffect(() => { loadUsers(); }, []);
const handleSave = (e) => { const handleSave = async (e) => {
e.preventDefault(); e.preventDefault();
if (!formData.name || !formData.pin) return; if (!formData.name || !formData.pin) return;
let updatedUsers = [...users];
if (editingId) { if (editingId) {
updatedUsers = updatedUsers.map(u => u.id === editingId ? { ...u, ...formData } : u); await db.updateTeamMember(editingId, { name: formData.name, role: formData.role, pin: formData.pin });
setMessage('User updated successfully.'); setMessage('User updated successfully.');
} else { } else {
const newUser = { await db.addTeamMember({ name: formData.name, role: formData.role, pin: formData.pin });
id: `u_${Date.now()}`,
name: formData.name,
role: formData.role,
pin: formData.pin,
registeredAt: new Date().toISOString()
};
updatedUsers.push(newUser);
setMessage('User added successfully.'); setMessage('User added successfully.');
} }
storage.set('users:registry', updatedUsers); await loadUsers();
setUsers(updatedUsers);
setFormData({ name: '', role: 'user', pin: '' }); setFormData({ name: '', role: 'user', pin: '' });
setIsEditing(false); setIsEditing(false);
setEditingId(null); setEditingId(null);
// In a real app we'd trigger a global state update, but here the user has to refresh or re-login if they edit themselves.
setTimeout(() => setMessage(''), 3000); setTimeout(() => setMessage(''), 3000);
}; };
@@ -59,20 +48,21 @@ const TeamManager = () => {
setEditingId(user.id); setEditingId(user.id);
}; };
const handleDelete = (id) => { const handleDelete = async (id) => {
if (id === state.currentUser?.id) { if (id === state.currentUser?.id) {
alert("You cannot delete yourself."); alert("You cannot delete yourself.");
return; return;
} }
if (confirm("Are you sure you want to delete this user?")) { if (confirm("Are you sure you want to delete this user?")) {
const updated = users.filter(u => u.id !== id); await db.deleteTeamMember(id);
storage.set('users:registry', updated);
setUsers(updated);
// Also remove them from leaderboard // Also remove from leaderboard
const lb = storage.get('leaderboard:current', []); try {
storage.set('leaderboard:current', lb.filter(e => e.userId !== id)); const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
await pb.collection('leaderboard').delete(entry.id);
} catch { /* no leaderboard entry, nothing to do */ }
await loadUsers();
setMessage('User deleted.'); setMessage('User deleted.');
setTimeout(() => setMessage(''), 3000); setTimeout(() => setMessage(''), 3000);
} }
@@ -92,7 +82,6 @@ const TeamManager = () => {
</div> </div>
)} )}
{/* Form */}
<Card className="border border-bg-warm"> <Card className="border border-bg-warm">
<h2 className="text-xl font-bold flex items-center gap-2 mb-4"> <h2 className="text-xl font-bold flex items-center gap-2 mb-4">
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />} {isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
@@ -143,7 +132,6 @@ const TeamManager = () => {
</form> </form>
</Card> </Card>
{/* List */}
<Card className="p-0 border border-bg-warm overflow-hidden"> <Card className="p-0 border border-bg-warm overflow-hidden">
<div className="divide-y divide-bg-warm"> <div className="divide-y divide-bg-warm">
{users.map(user => ( {users.map(user => (

245
src/lib/db.js Normal file
View File

@@ -0,0 +1,245 @@
import { pb } from './pb';
// ── Topics ──────────────────────────────────────────────────────────────────
export async function getTopics() {
return pb.collection('topics').getFullList({ sort: 'created' });
}
export async function saveTopics(topics) {
const existing = await pb.collection('topics').getFullList({ fields: 'id' });
await Promise.all(existing.map(r => pb.collection('topics').delete(r.id)));
return Promise.all(topics.map(t => pb.collection('topics').create({
id: t.id,
label: t.label,
type: t.type,
description: t.description,
})));
}
export async function upsertTopic(topic) {
try {
await pb.collection('topics').getOne(topic.id);
return pb.collection('topics').update(topic.id, topic);
} catch {
return pb.collection('topics').create({ id: topic.id, ...topic });
}
}
// ── Relations ────────────────────────────────────────────────────────────────
export async function getRelations() {
return pb.collection('relations').getFullList();
}
export async function saveRelations(relations) {
const existing = await pb.collection('relations').getFullList({ fields: 'id' });
await Promise.all(existing.map(r => pb.collection('relations').delete(r.id)));
return Promise.all(relations.map(r => pb.collection('relations').create(r)));
}
export async function addRelation(relation) {
return pb.collection('relations').create(relation);
}
export async function removeRelation(source, target, type) {
const records = await pb.collection('relations').getFullList({
filter: `source="${source}" && target="${target}" && type="${type}"`,
});
return Promise.all(records.map(r => pb.collection('relations').delete(r.id)));
}
// ── Content ──────────────────────────────────────────────────────────────────
export async function getContent(topicId) {
try {
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
return r.data;
} catch { return null; }
}
export async function setContent(topicId, data) {
try {
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
return pb.collection('content').update(r.id, { data });
} catch {
return pb.collection('content').create({ topic_id: topicId, data });
}
}
export async function deleteContent(topicId) {
try {
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
return pb.collection('content').delete(r.id);
} catch { /* no record, nothing to do */ }
}
// ── Quiz Banks ───────────────────────────────────────────────────────────────
export async function getQuizBank(topicId) {
try {
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
return r.questions || [];
} catch { return []; }
}
export async function setQuizBank(topicId, questions) {
try {
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
return pb.collection('quiz_banks').update(r.id, { questions });
} catch {
return pb.collection('quiz_banks').create({ topic_id: topicId, questions });
}
}
export async function deleteQuestionFromBank(topicId, questionId) {
const questions = await getQuizBank(topicId);
return setQuizBank(topicId, questions.filter(q => q.id !== questionId));
}
// ── Sources ──────────────────────────────────────────────────────────────────
export async function getSources() {
return pb.collection('sources').getFullList({ sort: '-created' });
}
export async function addSource(source) {
return pb.collection('sources').create(source);
}
export async function updateSourceStatus(id, status, error = '') {
return pb.collection('sources').update(id, { status, error });
}
export async function deleteSource(id) {
return pb.collection('sources').delete(id);
}
// ── Team Members ─────────────────────────────────────────────────────────────
export async function getTeamMembers() {
return pb.collection('team_members').getFullList({ sort: 'created' });
}
export async function addTeamMember(member) {
return pb.collection('team_members').create(member);
}
export async function updateTeamMember(id, data) {
return pb.collection('team_members').update(id, data);
}
export async function deleteTeamMember(id) {
return pb.collection('team_members').delete(id);
}
// ── Quiz Results ─────────────────────────────────────────────────────────────
export async function getQuizResult(userId, weekNumber) {
try {
return await pb.collection('quiz_results').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
} catch { return null; }
}
export async function saveQuizResult(userId, weekNumber, result) {
return pb.collection('quiz_results').create({
user_id: userId,
week_number: weekNumber,
score: result.score,
total: result.total,
percentage: result.percentage,
time_used: result.timeUsed,
completed_at: result.completedAt,
breakdown: result.breakdown,
points_earned: result.pointsEarned,
});
}
// ── Quiz Cache ────────────────────────────────────────────────────────────────
export async function getCachedQuiz(userId, weekNumber) {
try {
return await pb.collection('quiz_cache').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
} catch { return null; }
}
export async function setCachedQuiz(userId, weekNumber, quiz) {
try {
const r = await pb.collection('quiz_cache').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
return pb.collection('quiz_cache').update(r.id, { questions: quiz.questions, meta: quiz.meta });
} catch {
return pb.collection('quiz_cache').create({
user_id: userId, week_number: weekNumber, questions: quiz.questions, meta: quiz.meta,
});
}
}
// ── Learn Progress ────────────────────────────────────────────────────────────
export async function getLearnDone(userId, weekNumber) {
try {
const r = await pb.collection('learn_progress').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
return r.done;
} catch { return false; }
}
export async function setLearnDone(userId, weekNumber) {
try {
const r = await pb.collection('learn_progress').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
return pb.collection('learn_progress').update(r.id, { done: true });
} catch {
return pb.collection('learn_progress').create({ user_id: userId, week_number: weekNumber, done: true });
}
}
// ── Leaderboard ───────────────────────────────────────────────────────────────
export async function getLeaderboard() {
return pb.collection('leaderboard').getFullList({ sort: '-points' });
}
export async function upsertLeaderboardEntry(userId, name, pointsDelta, testsCompletedDelta = 0) {
try {
const r = await pb.collection('leaderboard').getFirstListItem(`user_id="${userId}"`);
return pb.collection('leaderboard').update(r.id, {
points: (r.points || 0) + pointsDelta,
tests_completed: (r.tests_completed || 0) + testsCompletedDelta,
});
} catch {
return pb.collection('leaderboard').create({
user_id: userId,
name,
points: pointsDelta,
tests_completed: testsCompletedDelta,
learnings_completed: 0,
});
}
}
// ── Settings ──────────────────────────────────────────────────────────────────
export async function getSetting(key, defaultValue = null) {
try {
const r = await pb.collection('settings').getFirstListItem(`key="${key}"`);
return r.value ?? defaultValue;
} catch { return defaultValue; }
}
export async function setSetting(key, value) {
try {
const r = await pb.collection('settings').getFirstListItem(`key="${key}"`);
return pb.collection('settings').update(r.id, { value: String(value) });
} catch {
return pb.collection('settings').create({ key, value: String(value) });
}
}

View File

@@ -1,5 +1,5 @@
import { anthropicApi } from './api'; import { anthropicApi } from './api';
import { storage } from './storage'; import * as db from './db';
const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency. const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
You receive a source text. Your task is to extract core concepts, roles, and processes, and return them as a structured JSON Knowledge Graph. You receive a source text. Your task is to extract core concepts, roles, and processes, and return them as a structured JSON Knowledge Graph.
@@ -25,32 +25,15 @@ ALWAYS return a valid JSON object in the following format:
} }
Return JSON only. No markdown blocks or other text.`; Return JSON only. No markdown blocks or other text.`;
/**
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
* @param {string} textContent De te analyseren tekst.
* @param {string} sourceName Naam van de bron (voor tracking).
*/
export async function processSourceText(textContent, sourceName) { export async function processSourceText(textContent, sourceName) {
// 1. Sla de bron eerst op als "processing" const rec = await db.addSource({ name: sourceName, status: 'processing' });
const sourceId = `src_${Date.now()}`; const sourceId = rec.id;
const newSource = {
id: sourceId,
name: sourceName,
status: 'processing',
date: new Date().toISOString()
};
const sources = storage.get('admin:sources', []);
storage.set('admin:sources', [newSource, ...sources]);
try { try {
// 2. Roep Anthropic API aan const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
// 3. Parse JSON (veilig)
let extractedData; let extractedData;
try { try {
// Zoek naar JSON in de response (indien het toch in markdown was verpakt)
const jsonMatch = responseText.match(/\{[\s\S]*\}/); const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : responseText; const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr); extractedData = JSON.parse(jsonStr);
@@ -58,34 +41,21 @@ export async function processSourceText(textContent, sourceName) {
throw new Error('AI response was not valid JSON.'); throw new Error('AI response was not valid JSON.');
} }
// 4. Deduplicatie en opslag van Topics en Relaties await mergeKnowledgeGraph(extractedData);
mergeKnowledgeGraph(extractedData); await db.updateSourceStatus(sourceId, 'completed');
// 5. Markeer bron als voltooid
updateSourceStatus(sourceId, 'completed');
return { success: true, data: extractedData }; return { success: true, data: extractedData };
} catch (error) { } catch (error) {
// Markeer bron als mislukt await db.updateSourceStatus(sourceId, 'failed', error.message);
updateSourceStatus(sourceId, 'failed', error.message);
throw error; throw error;
} }
} }
function updateSourceStatus(sourceId, status, errorMsg = '') { async function mergeKnowledgeGraph(newData) {
const sources = storage.get('admin:sources', []); const existingTopics = await db.getTopics();
const updated = sources.map(s => const existingRelations = await db.getRelations();
s.id === sourceId ? { ...s, status, error: errorMsg } : s
);
storage.set('admin:sources', updated);
}
function mergeKnowledgeGraph(newData) {
const existingTopics = storage.get('kb:topics', []);
const existingRelations = storage.get('kb:relations', []);
// Simpele deduplicatie op ID
const topicsMap = new Map(existingTopics.map(t => [t.id, t])); const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
if (newData.topics && Array.isArray(newData.topics)) { if (newData.topics && Array.isArray(newData.topics)) {
@@ -96,11 +66,9 @@ function mergeKnowledgeGraph(newData) {
} }
} }
// Voeg relaties toe (we gaan er nu vanuit dat ze uniek genoeg zijn of dubbele negeren)
const newRelations = [...existingRelations]; const newRelations = [...existingRelations];
if (newData.relations && Array.isArray(newData.relations)) { if (newData.relations && Array.isArray(newData.relations)) {
for (const r of newData.relations) { for (const r of newData.relations) {
// Simpele check om exacte duplicaten te voorkomen
const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type); const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
if (!isDup) { if (!isDup) {
newRelations.push(r); newRelations.push(r);
@@ -108,6 +76,6 @@ function mergeKnowledgeGraph(newData) {
} }
} }
storage.set('kb:topics', Array.from(topicsMap.values())); await db.saveTopics(Array.from(topicsMap.values()));
storage.set('kb:relations', newRelations); await db.saveRelations(newRelations);
} }

View File

@@ -1,5 +1,5 @@
import { anthropicApi } from './api'; import { anthropicApi } from './api';
import { storage } from './storage'; import * as db from './db';
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.
@@ -33,12 +33,8 @@ const CONTENT_SCHEMA = `{
} }
}`; }`;
/** export async function getAssignedTopic(userId, weekNumber) {
* Get the assigned topic for a user for a given week using round-robin. const topics = await db.getTopics();
* hash(userId + weekNumber) % topicCount
*/
export function getAssignedTopic(userId, weekNumber) {
const topics = storage.get('kb:topics', []);
if (!topics || topics.length === 0) return null; if (!topics || topics.length === 0) return null;
const str = `${userId}:${weekNumber}`; const str = `${userId}:${weekNumber}`;
@@ -51,43 +47,24 @@ export function getAssignedTopic(userId, weekNumber) {
return topics[index]; return topics[index];
} }
/** export async function getCachedContent(topicId) {
* Returns the cache key for a topic's content. return db.getContent(topicId);
*/
export function getContentCacheKey(topicId) {
return `kb:content:${topicId}`;
} }
/** export async function getAllGeneratedContent() {
* Returns cached content for a topic, or null if none exists. const topics = await db.getTopics();
*/ const results = await Promise.all(
export function getCachedContent(topicId) { topics.map(async topic => {
return storage.get(getContentCacheKey(topicId), null); const content = await db.getContent(topic.id);
return { topic, content, hasContent: !!content };
})
);
return results.filter(item => item.hasContent);
} }
/**
* List all topics that have generated content.
*/
export function getAllGeneratedContent() {
const topics = storage.get('kb:topics', []);
return topics
.map(topic => ({
topic,
content: getCachedContent(topic.id),
hasContent: !!getCachedContent(topic.id),
}))
.filter(item => item.hasContent);
}
/**
* Generate a complete learning module for a topic.
* Uses cached version if available (unless force = true).
*/
export async function generateLearningContent(topic, force = false) { export async function generateLearningContent(topic, force = false) {
const cacheKey = getContentCacheKey(topic.id);
if (!force) { if (!force) {
const cached = storage.get(cacheKey); const cached = await db.getContent(topic.id);
if (cached) { if (cached) {
console.log(`[Learn] Cache hit for topic: ${topic.id}`); console.log(`[Learn] Cache hit for topic: ${topic.id}`);
return cached; return cached;
@@ -115,17 +92,12 @@ Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the inf
throw new Error('AI could not generate valid learning content. Please try again.'); throw new Error('AI could not generate valid learning content. Please try again.');
} }
storage.set(cacheKey, content); await db.setContent(topic.id, content);
return content; return content;
} }
/**
* Refine existing content for a topic using a natural language instruction.
* Sends current content + refinement prompt to AI, saves new version.
*/
export async function refineLearningContent(topic, refinementInstruction) { export async function refineLearningContent(topic, refinementInstruction) {
const cacheKey = getContentCacheKey(topic.id); const existing = await db.getContent(topic.id);
const existing = storage.get(cacheKey);
const prompt = `You have previously generated the following learning module for the topic "${topic.label}": const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
@@ -146,20 +118,14 @@ Apply the refinement and return the complete updated JSON object using the same
throw new Error('AI could not process the refinement. Please try a different instruction.'); throw new Error('AI could not process the refinement. Please try a different instruction.');
} }
storage.set(cacheKey, content); await db.setContent(topic.id, content);
return content; return content;
} }
/** export async function deleteCachedContent(topicId) {
* Delete cached content for a topic, forcing a fresh generation next time. return db.deleteContent(topicId);
*/
export function deleteCachedContent(topicId) {
storage.remove(getContentCacheKey(topicId));
} }
/**
* Generate a new custom topic metadata on the fly from user input.
*/
export async function generateCustomTopic(label) { export async function generateCustomTopic(label) {
const prompt = `A user wants to learn about "${label}". const prompt = `A user wants to learn about "${label}".
Create a short description (2-3 sentences) and categorize it. Create a short description (2-3 sentences) and categorize it.
@@ -185,9 +151,6 @@ Return ONLY a JSON object with this structure:
throw new Error('Could not process custom topic. Please try again.'); throw new Error('Could not process custom topic. Please try again.');
} }
// Add to global knowledge base so others can see it too await db.upsertTopic(newTopic);
const topics = storage.get('kb:topics', []);
storage.set('kb:topics', [...topics, newTopic]);
return newTopic; return newTopic;
} }

6
src/lib/pb.js Normal file
View File

@@ -0,0 +1,6 @@
import PocketBase from 'pocketbase';
const pbUrl = import.meta.env.VITE_PB_URL ||
(typeof window !== 'undefined' ? window.location.origin + '/pb' : 'http://localhost:8090');
export const pb = new PocketBase(pbUrl);

View File

@@ -1,21 +1,15 @@
import { anthropicApi } from './api'; import { anthropicApi } from './api';
import { storage } from './storage'; import * as db from './db';
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.
Always write in clear, professional English. Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`; ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
/** async function selectTestTopics(userId, weekNumber) {
* Select topics for the weekly test: const topics = await db.getTopics();
* - 50% from the user's assigned learning topic this week if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [] };
* - 50% from random other topics (review)
*/
function selectTestTopics(userId, weekNumber) {
const topics = storage.get('kb:topics', []);
if (!topics || topics.length === 0) return [];
// Deterministic hash for the user's current topic
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++) {
@@ -25,7 +19,6 @@ function selectTestTopics(userId, weekNumber) {
const primaryIndex = Math.abs(hash) % topics.length; const primaryIndex = Math.abs(hash) % topics.length;
const primaryTopic = topics[primaryIndex]; const primaryTopic = topics[primaryIndex];
// Pick up to 5 "review" topics (random, different from primary)
const others = topics.filter((_, i) => i !== primaryIndex); const others = topics.filter((_, i) => i !== primaryIndex);
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));
@@ -33,19 +26,12 @@ function selectTestTopics(userId, weekNumber) {
return { primaryTopic, reviewTopics }; return { primaryTopic, reviewTopics };
} }
/** export async function getCachedQuiz(userId, weekNumber) {
* Retrieve cached quiz, or null. return db.getCachedQuiz(userId, weekNumber);
*/
export function getCachedQuiz(userId, weekNumber) {
return storage.get(`quiz:${userId}:week:${weekNumber}`, null);
} }
/**
* Exported helper for admin: manually trigger generation for a topic.
*/
export async function forceGenerateTopicQuestions(topic, count = 10) { export async function forceGenerateTopicQuestions(topic, count = 10) {
const bankKey = `quiz:bank:${topic.id}`; let bank = await db.getQuizBank(topic.id);
let bank = storage.get(bankKey, []);
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic: const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
@@ -88,76 +74,51 @@ Rules:
} }
bank = [...bank, ...newQuestions]; bank = [...bank, ...newQuestions];
storage.set(bankKey, bank); await db.setQuizBank(topic.id, bank);
return newQuestions; return newQuestions;
} }
/**
* Ensure a topic has enough questions in its bank, generating more if needed.
* Returns exactly `count` questions.
*/
async function getOrGenerateTopicQuestions(topic, count) { async function getOrGenerateTopicQuestions(topic, count) {
const bankKey = `quiz:bank:${topic.id}`; let bank = await db.getQuizBank(topic.id);
let bank = storage.get(bankKey, []);
// If we don't have enough questions, ask AI to generate a batch of 10
if (bank.length < count) { if (bank.length < count) {
await forceGenerateTopicQuestions(topic, 10); await forceGenerateTopicQuestions(topic, 10);
bank = storage.get(bankKey, []); // reload bank = await db.getQuizBank(topic.id);
} }
// Shuffle and pick `count` questions
const shuffled = [...bank].sort(() => 0.5 - Math.random()); const shuffled = [...bank].sort(() => 0.5 - Math.random());
return shuffled.slice(0, Math.min(count, shuffled.length)); return shuffled.slice(0, Math.min(count, shuffled.length));
} }
/** export async function getTopicQuestionBank(topicId) {
* Admin Helper: get question bank for a topic return db.getQuizBank(topicId);
*/
export function getTopicQuestionBank(topicId) {
return storage.get(`quiz:bank:${topicId}`, []);
} }
/** export async function deleteQuestion(topicId, questionId) {
* Admin Helper: delete a single question return db.deleteQuestionFromBank(topicId, questionId);
*/
export function deleteQuestion(topicId, questionId) {
const bankKey = `quiz:bank:${topicId}`;
const bank = storage.get(bankKey, []);
storage.set(bankKey, bank.filter(q => q.id !== questionId));
} }
/**
* Generate 10 MCQ questions for a user's weekly test.
* Caches the result so the same quiz is served on retry.
* Pulls from topic-specific question banks, generating more if banks are low.
*/
export async function generateWeeklyQuiz(userId, weekNumber, force = false) { export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
const cacheKey = `quiz:${userId}:week:${weekNumber}`;
if (!force) { if (!force) {
const cached = storage.get(cacheKey); const cached = await db.getCachedQuiz(userId, weekNumber);
if (cached) return cached; if (cached) return cached;
} }
const { primaryTopic, reviewTopics } = selectTestTopics(userId, weekNumber); const { primaryTopic, reviewTopics } = await selectTestTopics(userId, weekNumber);
if (!primaryTopic) throw new Error('No topics available to generate a quiz.'); if (!primaryTopic) throw new Error('No topics available to generate a quiz.');
const questions = []; const questions = [];
// Get 5 questions for the primary topic
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5); const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
questions.push(...primaryQs); questions.push(...primaryQs);
// Get 1 question for each of the up to 5 review topics
for (const rt of reviewTopics) { for (const rt of reviewTopics) {
const rtQs = await getOrGenerateTopicQuestions(rt, 1); const rtQs = await getOrGenerateTopicQuestions(rt, 1);
questions.push(...rtQs); questions.push(...rtQs);
} }
// If there are fewer than 10 questions (e.g. no review topics yet), pad with more from primary
if (questions.length < 10) { if (questions.length < 10) {
const needed = 10 - questions.length; const needed = 10 - questions.length;
// We already took 5, so let's try to get enough extra unique questions
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5); const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
const existingIds = new Set(questions.map(q => q.id)); const existingIds = new Set(questions.map(q => q.id));
for (const eq of extraQs) { for (const eq of extraQs) {
@@ -167,58 +128,33 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
} }
} }
// Shuffle the final 10 questions
questions.sort(() => 0.5 - Math.random()); questions.sort(() => 0.5 - Math.random());
const quiz = { questions }; const quiz = {
quiz.meta = { questions,
userId, meta: {
weekNumber, userId,
generatedAt: new Date().toISOString(), weekNumber,
primaryTopic: primaryTopic.label, generatedAt: new Date().toISOString(),
primaryTopic: primaryTopic.label,
},
}; };
storage.set(cacheKey, quiz); await db.setCachedQuiz(userId, weekNumber, quiz);
return quiz; return quiz;
} }
/** export async function saveTestResult(userId, weekNumber, result) {
* Save a completed test result. await db.saveQuizResult(userId, weekNumber, result);
*/
export function saveTestResult(userId, weekNumber, result) {
const key = `quiz:result:${userId}:week:${weekNumber}`;
storage.set(key, result);
// Update leaderboard points const pointsEarned = result.score * 2;
const leaderboard = storage.get('leaderboard:current', []); const members = await db.getTeamMembers();
const entry = leaderboard.find(e => e.userId === userId); const member = members.find(m => m.id === userId);
const pointsEarned = result.score * 2; // 2 pts per correct answer await db.upsertLeaderboardEntry(userId, member?.name || 'Unknown', pointsEarned, 1);
if (entry) {
entry.points += pointsEarned;
entry.testsCompleted = (entry.testsCompleted || 0) + 1;
} else {
const users = storage.get('users:registry', []);
const user = users.find(u => u.id === userId);
leaderboard.push({
userId,
name: user?.name || 'Unknown',
points: pointsEarned,
testsCompleted: 1,
learningsCompleted: 0,
});
}
// Sort desc
leaderboard.sort((a, b) => b.points - a.points);
storage.set('leaderboard:current', leaderboard);
return { pointsEarned }; return { pointsEarned };
} }
/** export async function getTestResult(userId, weekNumber) {
* Get a previously completed test result, or null. return db.getQuizResult(userId, weekNumber);
*/
export function getTestResult(userId, weekNumber) {
return storage.get(`quiz:result:${userId}:week:${weekNumber}`, null);
} }

View File

@@ -5,6 +5,7 @@ import Tag from '../../components/ui/Tag';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input'; import Input from '../../components/ui/Input';
import { storage } from '../../lib/storage'; import { storage } from '../../lib/storage';
import * as db from '../../lib/db';
import UploadZone from '../../components/admin/UploadZone'; import UploadZone from '../../components/admin/UploadZone';
import KnowledgeGraph from '../../components/admin/KnowledgeGraph'; import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
import ContentManager from '../../components/admin/ContentManager'; import ContentManager from '../../components/admin/ContentManager';
@@ -20,8 +21,9 @@ const Admin = () => {
const [useSimulation, setUseSimulation] = useState(false); const [useSimulation, setUseSimulation] = useState(false);
const [saveStatus, setSaveStatus] = useState(null); const [saveStatus, setSaveStatus] = useState(null);
const loadSources = () => { const loadSources = async () => {
setSources(storage.get('admin:sources', [])); const data = await db.getSources();
setSources(data);
}; };
useEffect(() => { useEffect(() => {
@@ -44,11 +46,10 @@ const Admin = () => {
setTimeout(() => setSaveStatus(null), 3000); setTimeout(() => setSaveStatus(null), 3000);
}; };
const handleDeleteSource = (id) => { const handleDeleteSource = async (id) => {
if (confirm('Are you sure you want to delete this source? This will not delete topics already extracted.')) { if (confirm('Are you sure you want to delete this source? This will not delete topics already extracted.')) {
const updated = sources.filter(s => s.id !== id); await db.deleteSource(id);
storage.set('admin:sources', updated); await loadSources();
setSources(updated);
} }
}; };
@@ -63,7 +64,6 @@ const Admin = () => {
return ( return (
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden"> <div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
{/* Admin Sidebar */}
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto"> <div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Knowledge Mgmt</h2> <h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Knowledge Mgmt</h2>
@@ -79,10 +79,8 @@ const Admin = () => {
))} ))}
</div> </div>
{/* Admin Main Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8"> <div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
{/* ── Sources ─────────────────────────────── */}
{activeTab === 'sources' && ( {activeTab === 'sources' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto"> <div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Source Material</h1> <h1 className="text-3xl text-teal mb-2">Source Material</h1>
@@ -109,7 +107,7 @@ const Admin = () => {
<div> <div>
<p className="font-medium">{source.name}</p> <p className="font-medium">{source.name}</p>
<p className="text-xs text-fg-muted flex items-center gap-1 mt-1"> <p className="text-xs text-fg-muted flex items-center gap-1 mt-1">
<Clock size={12} /> {new Date(source.date).toLocaleString()} <Clock size={12} /> {new Date(source.created).toLocaleString()}
</p> </p>
</div> </div>
</div> </div>
@@ -129,7 +127,6 @@ const Admin = () => {
</div> </div>
)} )}
{/* ── Content Manager ──────────────────────── */}
{activeTab === 'content' && ( {activeTab === 'content' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto"> <div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Learning Content</h1> <h1 className="text-3xl text-teal mb-2">Learning Content</h1>
@@ -138,7 +135,6 @@ const Admin = () => {
</div> </div>
)} )}
{/* ── Test Manager ─────────────────────────── */}
{activeTab === 'tests' && ( {activeTab === 'tests' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto"> <div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Question Banks</h1> <h1 className="text-3xl text-teal mb-2">Question Banks</h1>
@@ -147,18 +143,16 @@ const Admin = () => {
</div> </div>
)} )}
{/* ── Knowledge Graph ──────────────────────── */}
{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>
<p className="text-fg-muted mb-4">Visual map of all extracted Respellion knowledge.</p> <p className="text-fg-muted mb-4">Visual map of all extracted Respellion knowledge.</p>
<Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]"> <Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]">
<KnowledgeGraph /> <KnowledgeGraph />
</Card> </Card>
</div> </div>
)} )}
{/* ── Team ────────────────────────────────── */}
{activeTab === 'team' && ( {activeTab === 'team' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto"> <div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Team Management</h1> <h1 className="text-3xl text-teal mb-2">Team Management</h1>
@@ -167,7 +161,6 @@ const Admin = () => {
</div> </div>
)} )}
{/* ── Settings ────────────────────────────── */}
{activeTab === 'settings' && ( {activeTab === 'settings' && (
<div className="animate-in fade-in duration-300 max-w-2xl"> <div className="animate-in fade-in duration-300 max-w-2xl">
<h1 className="text-3xl text-teal mb-2">Settings</h1> <h1 className="text-3xl text-teal mb-2">Settings</h1>

View File

@@ -1,47 +1,62 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useApp } from '../store/AppContext'; import { useApp } from '../store/AppContext';
import Card from '../components/ui/Card'; import Card from '../components/ui/Card';
import Button from '../components/ui/Button'; 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 { storage } from '../lib/storage';
import { getAssignedTopic } from '../lib/learningService'; import { getAssignedTopic } from '../lib/learningService';
import { getTestResult } from '../lib/testService';
const Dashboard = () => { const Dashboard = () => {
const { state } = useApp(); const { state } = useApp();
const { currentUser, weekNumber } = state; const { currentUser, weekNumber } = state;
const topic = getAssignedTopic(currentUser?.id, weekNumber); const [dashData, setDashData] = useState({
const learnDone = storage.get(`user:${currentUser?.id}:week:${weekNumber}:learn_done`, false); topic: null,
const testResult = getTestResult(currentUser?.id, weekNumber); learnDone: false,
testResult: null,
top3: [],
myRank: 0,
myPoints: 0,
activity: [],
});
const allUsers = storage.get('users:registry', []); useEffect(() => {
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id); if (!currentUser) return;
const leaderboard = storage.get('leaderboard:current', []) const load = async () => {
.filter(u => nonAdminIds.includes(u.userId)) const [topic, learnDone, testResult, allUsers, leaderboard] = await Promise.all([
.sort((a, b) => b.points - a.points); getAssignedTopic(currentUser.id, weekNumber),
db.getLearnDone(currentUser.id, weekNumber),
db.getQuizResult(currentUser.id, weekNumber),
db.getTeamMembers(),
db.getLeaderboard(),
]);
const top3 = leaderboard.slice(0, 3); const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
const myRank = leaderboard.findIndex(u => u.userId === currentUser?.id) + 1; const filtered = leaderboard.filter(u => nonAdminIds.includes(u.user_id));
const myPoints = leaderboard.find(u => u.userId === currentUser?.id)?.points || 0; const top3 = filtered.slice(0, 3);
const myRank = filtered.findIndex(u => u.user_id === currentUser.id) + 1;
const myPoints = filtered.find(u => u.user_id === currentUser.id)?.points || 0;
// Gather recent activity from past weeks const activity = [];
const activity = []; for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) { const [pastLearn, pastTest, pastTopic] = await Promise.all([
const pastLearn = storage.get(`user:${currentUser?.id}:week:${w}:learn_done`, false); db.getLearnDone(currentUser.id, w),
const pastTest = getTestResult(currentUser?.id, w); db.getQuizResult(currentUser.id, w),
const pastTopic = getAssignedTopic(currentUser?.id, w); getAssignedTopic(currentUser.id, w),
]);
if (pastTest) activity.push({ type: 'test', week: w, topic: pastTopic?.label, score: pastTest.percentage, points: pastTest.points_earned });
if (pastLearn) activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
}
if (pastTest) { setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity });
activity.push({ type: 'test', week: w, topic: pastTopic?.label, score: pastTest.percentage, points: pastTest.pointsEarned }); };
}
if (pastLearn) { load();
activity.push({ type: 'learn', week: w, topic: pastTopic?.label }); }, [currentUser, weekNumber]);
}
} const { topic, learnDone, testResult, top3, myRank, myPoints, activity } = dashData;
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">
@@ -127,10 +142,10 @@ const Dashboard = () => {
<div className="text-center text-fg-muted py-4">No points yet</div> <div className="text-center text-fg-muted py-4">No points yet</div>
) : ( ) : (
top3.map((u, i) => ( top3.map((u, i) => (
<div key={u.userId} className="flex items-center justify-between"> <div key={u.user_id} className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`font-mono font-bold ${i === 0 ? 'text-yellow-500' : 'text-teal'}`}>{i + 1}.</span> <span className={`font-mono font-bold ${i === 0 ? 'text-yellow-500' : 'text-teal'}`}>{i + 1}.</span>
<span className="font-medium">{u.name} {u.userId === currentUser?.id && '(You)'}</span> <span className="font-medium">{u.name} {u.user_id === currentUser?.id && '(You)'}</span>
</div> </div>
<Tag variant="dark">{u.points} pts</Tag> <Tag variant="dark">{u.points} pts</Tag>
</div> </div>

View File

@@ -4,12 +4,12 @@ import { motion } from 'framer-motion';
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 { useApp } from '../store/AppContext'; import { useApp } from '../store/AppContext';
import { storage } from '../lib/storage'; import * as db from '../lib/db';
const BADGE_RULES = [ const BADGE_RULES = [
{ id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.testsCompleted > 0 }, { id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.tests_completed > 0 },
{ id: 'perfectionist', icon: Award, label: 'Perfectionist', condition: (u) => u.perfectScores > 0 }, { id: 'perfectionist', icon: Award, label: 'Perfectionist', condition: (u) => u.perfectScores > 0 },
{ id: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.testsCompleted >= 5 }, { id: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.tests_completed >= 5 },
]; ];
const Leaderboard = () => { const Leaderboard = () => {
@@ -17,40 +17,38 @@ const Leaderboard = () => {
const [board, setBoard] = useState([]); const [board, setBoard] = useState([]);
useEffect(() => { useEffect(() => {
// Re-evaluate badges before displaying const load = async () => {
let data = storage.get('leaderboard:current', []); const [leaderboardData, allUsers] = await Promise.all([
const allUsers = storage.get('users:registry', []); db.getLeaderboard(),
db.getTeamMembers(),
]);
// Filter out admins from the leaderboard entirely const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id); let data = leaderboardData.filter(entry => nonAdminIds.includes(entry.user_id));
data = data.filter(entry => nonAdminIds.includes(entry.userId));
const userMap = new Map(data.map(u => [u.userId, u])); // Ensure all non-admin users appear even if they have no points yet
const inBoard = new Set(data.map(e => e.user_id));
allUsers.forEach(user => { for (const user of allUsers) {
if (!userMap.has(user.id) && user.role !== 'admin') { if (!inBoard.has(user.id) && user.role !== 'admin') {
data.push({ data.push({ user_id: user.id, name: user.name, points: 0, tests_completed: 0, perfectScores: 0 });
userId: user.id, }
name: user.name,
points: 0,
testsCompleted: 0,
});
} }
});
// Check for perfect scores by peeking at test results // Compute perfect scores per user
data = data.map(entry => { data = await Promise.all(data.map(async entry => {
let perfectScores = 0; let perfectScores = 0;
// Loop over all possible weeks for (let w = 1; w <= state.weekNumber; w++) {
for (let w = 1; w <= state.weekNumber; w++) { const result = await db.getQuizResult(entry.user_id, w);
const result = storage.get(`quiz:result:${entry.userId}:week:${w}`); if (result && result.percentage === 100) perfectScores++;
if (result && result.percentage === 100) perfectScores++; }
} return { ...entry, perfectScores };
return { ...entry, perfectScores }; }));
});
data.sort((a, b) => b.points - a.points); data.sort((a, b) => b.points - a.points);
setBoard(data); setBoard(data);
};
load();
}, [state.weekNumber]); }, [state.weekNumber]);
return ( return (
@@ -61,10 +59,8 @@ const Leaderboard = () => {
<p className="text-fg-muted">Compete, learn, and earn badges based on your weekly knowledge tests.</p> <p className="text-fg-muted">Compete, learn, and earn badges based on your weekly knowledge tests.</p>
</div> </div>
{/* Top 3 Podium */}
{board.length >= 3 && ( {board.length >= 3 && (
<div className="flex justify-center items-end gap-2 md:gap-6 mb-16 h-64 mt-12"> <div className="flex justify-center items-end gap-2 md:gap-6 mb-16 h-64 mt-12">
{/* 2nd Place */}
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="w-1/3 max-w-[140px] flex flex-col items-center"> <motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
<div className="relative mb-4"> <div className="relative mb-4">
<div className="w-16 h-16 rounded-full bg-slate-200 border-4 border-paper flex items-center justify-center text-slate-500 font-bold text-2xl shadow-lg z-10 relative">2</div> <div className="w-16 h-16 rounded-full bg-slate-200 border-4 border-paper flex items-center justify-center text-slate-500 font-bold text-2xl shadow-lg z-10 relative">2</div>
@@ -75,7 +71,6 @@ const Leaderboard = () => {
<div className="w-full h-24 bg-gradient-to-t from-slate-200/50 to-transparent rounded-t-[var(--r-sm)] mt-4"></div> <div className="w-full h-24 bg-gradient-to-t from-slate-200/50 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
</motion.div> </motion.div>
{/* 1st Place */}
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="w-1/3 max-w-[160px] flex flex-col items-center z-10"> <motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="w-1/3 max-w-[160px] flex flex-col items-center z-10">
<div className="relative mb-6"> <div className="relative mb-6">
<div className="w-20 h-20 rounded-full bg-yellow-400 border-4 border-paper flex items-center justify-center text-yellow-800 font-bold text-3xl shadow-xl z-10 relative">1</div> <div className="w-20 h-20 rounded-full bg-yellow-400 border-4 border-paper flex items-center justify-center text-yellow-800 font-bold text-3xl shadow-xl z-10 relative">1</div>
@@ -87,7 +82,6 @@ const Leaderboard = () => {
<div className="w-full h-32 bg-gradient-to-t from-yellow-400/20 to-transparent rounded-t-[var(--r-sm)] mt-4 border-t border-yellow-400/30"></div> <div className="w-full h-32 bg-gradient-to-t from-yellow-400/20 to-transparent rounded-t-[var(--r-sm)] mt-4 border-t border-yellow-400/30"></div>
</motion.div> </motion.div>
{/* 3rd Place */}
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.4 }} className="w-1/3 max-w-[140px] flex flex-col items-center"> <motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.4 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
<div className="relative mb-4"> <div className="relative mb-4">
<div className="w-16 h-16 rounded-full bg-amber-700 border-4 border-paper flex items-center justify-center text-white font-bold text-2xl shadow-lg z-10 relative">3</div> <div className="w-16 h-16 rounded-full bg-amber-700 border-4 border-paper flex items-center justify-center text-white font-bold text-2xl shadow-lg z-10 relative">3</div>
@@ -100,22 +94,20 @@ const Leaderboard = () => {
</div> </div>
)} )}
{/* Full List */}
<Card className="border border-bg-warm p-0 overflow-hidden"> <Card className="border border-bg-warm p-0 overflow-hidden">
<div className="divide-y divide-bg-warm"> <div className="divide-y divide-bg-warm">
{board.map((user, index) => { {board.map((user, index) => {
const earnedBadges = BADGE_RULES.filter(r => r.condition(user)); const earnedBadges = BADGE_RULES.filter(r => r.condition(user));
const isMe = user.userId === state.currentUser?.id; const isMe = user.user_id === state.currentUser?.id;
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }} transition={{ delay: index * 0.05 }}
key={user.userId} key={user.user_id}
className={`p-4 flex flex-col sm:flex-row sm:items-center gap-4 transition-colors ${isMe ? 'bg-teal/5 border-l-4 border-teal' : 'hover:bg-bg-warm/30'}`} className={`p-4 flex flex-col sm:flex-row sm:items-center gap-4 transition-colors ${isMe ? 'bg-teal/5 border-l-4 border-teal' : 'hover:bg-bg-warm/30'}`}
> >
{/* Rank & Name */}
<div className="flex items-center gap-4 min-w-[200px]"> <div className="flex items-center gap-4 min-w-[200px]">
<span className={`font-mono text-lg font-bold w-6 text-center ${ <span className={`font-mono text-lg font-bold w-6 text-center ${
index === 0 ? 'text-yellow-500' : index === 0 ? 'text-yellow-500' :
@@ -130,12 +122,11 @@ const Leaderboard = () => {
{isMe && <Tag variant="accent" className="text-[10px]">You</Tag>} {isMe && <Tag variant="accent" className="text-[10px]">You</Tag>}
</p> </p>
<p className="text-xs text-fg-muted flex items-center gap-1"> <p className="text-xs text-fg-muted flex items-center gap-1">
<CheckSquare size={12} /> {user.testsCompleted || 0} tests completed <CheckSquare size={12} /> {user.tests_completed || 0} tests completed
</p> </p>
</div> </div>
</div> </div>
{/* Points */}
<div className="flex-1 flex sm:justify-center"> <div className="flex-1 flex sm:justify-center">
<div className="flex items-center gap-1.5 text-teal font-bold bg-teal/10 px-3 py-1 rounded-full text-sm"> <div className="flex items-center gap-1.5 text-teal font-bold bg-teal/10 px-3 py-1 rounded-full text-sm">
<TrendingUp size={14} /> <TrendingUp size={14} />
@@ -143,7 +134,6 @@ const Leaderboard = () => {
</div> </div>
</div> </div>
{/* Badges */}
<div className="flex gap-2 sm:justify-end min-w-[120px]"> <div className="flex gap-2 sm:justify-end min-w-[120px]">
{earnedBadges.length > 0 ? ( {earnedBadges.length > 0 ? (
earnedBadges.map(b => ( earnedBadges.map(b => (

View File

@@ -9,7 +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 { storage } from '../lib/storage'; import * as db from '../lib/db';
const Leren = () => { const Leren = () => {
const { state } = useApp(); const { state } = useApp();
@@ -39,12 +39,17 @@ const Leren = () => {
useEffect(() => { useEffect(() => {
if (state.currentUser) { if (state.currentUser) {
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber); const load = async () => {
setAssignedTopic(assigned); const [assigned, topics, done] = await Promise.all([
setAllTopics(storage.get('kb:topics', [])); getAssignedTopic(state.currentUser.id, state.weekNumber),
db.getTopics(),
const done = storage.get(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, false); db.getLearnDone(state.currentUser.id, state.weekNumber),
if (done) setWeeklyDone(true); ]);
setAssignedTopic(assigned);
setAllTopics(topics);
if (done) setWeeklyDone(true);
};
load();
} }
}, [state.currentUser, state.weekNumber]); }, [state.currentUser, state.weekNumber]);
@@ -97,11 +102,11 @@ const Leren = () => {
} }
}; };
const doComplete = () => { const doComplete = async () => {
setSessionDone(true); setSessionDone(true);
if (!weeklyDone) { if (!weeklyDone) {
setWeeklyDone(true); setWeeklyDone(true);
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true); await db.setLearnDone(state.currentUser.id, state.weekNumber);
} }
}; };
@@ -114,14 +119,12 @@ const Leren = () => {
doComplete(); doComplete();
}; };
const handleSubmitFeedback = () => { const handleSubmitFeedback = async () => {
if (feedbackText.trim()) { if (feedbackText.trim()) {
storage.set(`user:${state.currentUser.id}:feedback:${activeTopic.id}`, { await db.setSetting(
text: feedbackText.trim(), `feedback:${state.currentUser.id}:${activeTopic.id}:${state.weekNumber}`,
topicId: activeTopic.id, feedbackText.trim()
week: state.weekNumber, );
timestamp: Date.now(),
});
} }
setShowFeedbackModal(false); setShowFeedbackModal(false);
doComplete(); doComplete();

View File

@@ -1,12 +1,12 @@
import React, { createContext, useContext, useReducer, useEffect } from 'react'; import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { storage } from '../lib/storage'; import * as db from '../lib/db';
const AppContext = createContext(); const AppContext = createContext();
const initialState = { const initialState = {
currentUser: null, // will hold user object if logged in currentUser: null,
users: [], // array of registered users users: [],
weekNumber: 1, // current learning week weekNumber: 1,
isLoading: true isLoading: true
}; };
@@ -20,25 +20,11 @@ function appReducer(state, action) {
isLoading: false isLoading: false
}; };
case 'LOGIN': case 'LOGIN':
return { return { ...state, currentUser: action.payload };
...state,
currentUser: action.payload
};
case 'LOGOUT': case 'LOGOUT':
return { return { ...state, currentUser: null };
...state,
currentUser: null
};
case 'REGISTER_USER':
return {
...state,
users: [...state.users, action.payload]
};
case 'ADVANCE_WEEK': case 'ADVANCE_WEEK':
return { return { ...state, weekNumber: state.weekNumber + 1 };
...state,
weekNumber: state.weekNumber + 1
};
default: default:
return state; return state;
} }
@@ -47,48 +33,31 @@ function appReducer(state, action) {
export function AppProvider({ children }) { export function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState); const [state, dispatch] = useReducer(appReducer, initialState);
// Initialize app state from storage
useEffect(() => { useEffect(() => {
const loadState = () => { const loadState = async () => {
let users = storage.get('users:registry'); let members = await db.getTeamMembers();
// Seed first admin if no users exist if (!members || members.length === 0) {
if (!users || users.length === 0) { const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
const initialAdmin = { members = [created];
id: 'u_1',
name: 'Admin',
role: 'admin',
pin: '0000',
registeredAt: new Date().toISOString()
};
users = [initialAdmin];
storage.set('users:registry', users);
// Also seed initial empty leaderboard
storage.set('leaderboard:current', []);
} }
const storedWeek = storage.get('admin:current_week') || 1; const storedWeek = Number(await db.getSetting('admin:current_week', 1));
// Automatically login if we saved session (optional, simpler to require login per session)
const sessionUserId = sessionStorage.getItem('respellion_session'); const sessionUserId = sessionStorage.getItem('respellion_session');
if (sessionUserId) { if (sessionUserId) {
const user = users.find(u => u.id === sessionUserId); const user = members.find(u => u.id === sessionUserId);
if (user) { if (user) {
dispatch({ type: 'LOGIN', payload: user }); dispatch({ type: 'LOGIN', payload: user });
} }
} }
dispatch({ dispatch({ type: 'INIT_APP', payload: { users: members, weekNumber: storedWeek } });
type: 'INIT_APP',
payload: { users, weekNumber: storedWeek }
});
}; };
loadState(); loadState().catch(console.error);
}, []); }, []);
// Expose dispatch actions as convenient methods
const login = (userId, pin) => { const login = (userId, pin) => {
const user = state.users.find(u => u.id === userId && u.pin === pin); const user = state.users.find(u => u.id === userId && u.pin === pin);
if (user) { if (user) {