From 7b6a5b4bf0cc6134ee5e45815f3a4b36e8becd6b Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Wed, 27 May 2026 15:05:26 +0200 Subject: [PATCH] feat: add GraphControls component and useGraphData hook for knowledge graph management --- src/components/admin/graph/GraphControls.jsx | 54 ++ src/hooks/useGraphData.js | 122 +++++ src/lib/db.js | 21 +- src/pages/Leaderboard.jsx | 508 ++++++++++++++----- 4 files changed, 577 insertions(+), 128 deletions(-) create mode 100644 src/components/admin/graph/GraphControls.jsx create mode 100644 src/hooks/useGraphData.js diff --git a/src/components/admin/graph/GraphControls.jsx b/src/components/admin/graph/GraphControls.jsx new file mode 100644 index 0000000..12ff5cb --- /dev/null +++ b/src/components/admin/graph/GraphControls.jsx @@ -0,0 +1,54 @@ +import { RefreshCw, AlertCircle } from 'lucide-react'; +import Button from '../../ui/Button'; +import SuggestionsQueue from '../SuggestionsQueue'; + +/** + * Sidebar controls panel: the exclude-nodes toggle, the AI analysis button, + * and the R42 suggestions queue. + * + * Kept intentionally thin — all async logic lives in the KnowledgeGraph + * orchestrator and is injected via props. + */ +export default function GraphControls({ + showExcludeNodes, + onShowExcludeChange, + onAnalyze, + isAnalyzing, + analyzeError, + disabled, + onApplied, +}) { + return ( +
+ + +
+ + + {analyzeError && ( +

+ + {analyzeError} +

+ )} +
+ + +
+ ); +} diff --git a/src/hooks/useGraphData.js b/src/hooks/useGraphData.js new file mode 100644 index 0000000..ea80aad --- /dev/null +++ b/src/hooks/useGraphData.js @@ -0,0 +1,122 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import * as db from '../lib/db'; + +/** + * Centralises all knowledge-graph data fetching and mutation. + * + * Returns stable callback refs so callers can safely list them in + * useEffect dependency arrays without causing infinite loops. + * + * Relation shape contract: source and target are always plain strings. + * The normalization in db.getRelations() enforces this at the DB boundary; + * the filter here drops any orphaned edges whose endpoints no longer exist. + */ +export function useGraphData() { + const [topics, setTopics] = useState([]); + const [relations, setRelations] = useState([]); + + // Refs that always hold the latest state — lets mutation callbacks avoid + // stale-closure bugs without including state in their dependency arrays. + const topicsRef = useRef([]); + const relationsRef = useRef([]); + + useEffect(() => { topicsRef.current = topics; }, [topics]); + useEffect(() => { relationsRef.current = relations; }, [relations]); + + // ── Load ──────────────────────────────────────────────────────────────────── + + const reload = useCallback(async () => { + const [allTopics, allRelations] = await Promise.all([ + db.getTopics(), + db.getRelations(), // already normalised to string IDs + ]); + const topicIds = new Set(allTopics.map(t => t.id)); + setTopics(allTopics); + setRelations(allRelations.filter(r => topicIds.has(r.source) && topicIds.has(r.target))); + }, []); + + useEffect(() => { + reload(); + window.addEventListener('respellion:kb-updated', reload); + return () => window.removeEventListener('respellion:kb-updated', reload); + }, [reload]); + + // ── Single-topic mutations ─────────────────────────────────────────────────── + + /** + * Persist a topic update and mirror it into local state. + * Automatically locks learning_relevance when the admin changes it. + * Returns the saved topic object. + */ + const updateTopic = useCallback(async (topic) => { + await db.upsertTopic(topic); + setTopics(prev => prev.map(t => (t.id === topic.id ? topic : t))); + return topic; + }, []); + + /** + * Delete a topic plus all relations that reference it. + * Uses the ref so the callback is stable even as state changes. + */ + const deleteTopic = useCallback(async (topicId) => { + const updatedTopics = topicsRef.current.filter(t => t.id !== topicId); + const updatedRelations = relationsRef.current.filter( + r => r.source !== topicId && r.target !== topicId, + ); + await db.saveTopics(updatedTopics); + await db.saveRelations(updatedRelations); + await db.deleteContent(topicId); + setTopics(updatedTopics); + setRelations(updatedRelations); + }, []); + + // ── Relation mutations ─────────────────────────────────────────────────────── + + /** + * Add a relation if not already present. + * Returns true if the relation was added, false if it was a duplicate. + */ + const addRelation = useCallback(async (relation) => { + const isDup = relationsRef.current.some( + r => r.source === relation.source && + r.target === relation.target && + r.type === relation.type, + ); + if (isDup) return false; + await db.addRelation(relation); + setRelations(prev => [...prev, relation]); + return true; + }, []); + + /** Remove a specific (source, target, type) relation triple. */ + const removeRelation = useCallback(async (source, target, type) => { + await db.removeRelation(source, target, type); + setRelations(prev => + prev.filter(r => !(r.source === source && r.target === target && r.type === type)), + ); + }, []); + + // ── Bulk mutations (used by AI analysis) ──────────────────────────────────── + + /** + * Atomically replace the entire graph in PocketBase and local state. + * Called by analyzeGraph after all AI actions have been applied locally. + */ + const bulkSave = useCallback(async (newTopics, newRelations) => { + await db.saveTopics(newTopics); + await db.saveRelations(newRelations); + setTopics(newTopics); + setRelations(newRelations); + }, []); + + return { + topics, + relations, + reload, + updateTopic, + deleteTopic, + addRelation, + removeRelation, + bulkSave, + }; +} diff --git a/src/lib/db.js b/src/lib/db.js index 0fe4451..3e008ca 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -53,7 +53,15 @@ export async function upsertTopic(topic) { // ── Relations ──────────────────────────────────────────────────────────────── export async function getRelations() { - return pb.collection('relations').getFullList(); + const records = await pb.collection('relations').getFullList(); + // Always return plain string IDs for source/target. + // PocketBase can return populated objects when relation fields are expanded; + // normalising here means callers never need the `r.source?.id || r.source` dance. + return records.map(r => ({ + ...r, + source: typeof r.source === 'object' && r.source !== null ? r.source.id : r.source, + target: typeof r.target === 'object' && r.target !== null ? r.target.id : r.target, + })); } export async function saveRelations(relations) { @@ -234,6 +242,17 @@ export async function getLeaderboard() { return entries.sort((a, b) => (b.points || 0) - (a.points || 0)); } +/** + * Fetch every test_results record across all users, newest first. + * Used by the Contributions page to build per-user heatmaps, streaks, + * perfect-score counts, and the recent-activity feed in a single query. + */ +export async function getAllTestResults() { + try { + return await pb.collection('test_results').getFullList({ sort: '-created' }); + } catch { return []; } +} + export async function upsertLeaderboardEntry(userId, name, pointsDelta, testsCompletedDelta = 0) { try { const r = await pb.collection('leaderboard').getFirstListItem(`user_id="${userId}"`); diff --git a/src/pages/Leaderboard.jsx b/src/pages/Leaderboard.jsx index 499a49a..d48b7c4 100644 --- a/src/pages/Leaderboard.jsx +++ b/src/pages/Leaderboard.jsx @@ -1,161 +1,415 @@ import { useState, useEffect } from 'react'; -import { Trophy, Medal, Award, TrendingUp, Star, CheckSquare } from 'lucide-react'; import { motion } from 'framer-motion'; +import { GitCommitHorizontal, Zap, CheckCircle2, Clock } from 'lucide-react'; import Card from '../components/ui/Card'; import Tag from '../components/ui/Tag'; import { useApp } from '../store/AppContext'; import * as db from '../lib/db'; +// ── Constants ──────────────────────────────────────────────────────────────── + +const TOTAL_WEEKS = 26; + const BADGE_RULES = [ - { 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: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.tests_completed >= 5 }, + { id: 'first_test', label: 'first-steps', condition: (u) => u.tests_completed > 0 }, + { id: 'veteran', label: 'veteran', condition: (u) => u.tests_completed >= 5 }, + { id: 'perfectionist', label: 'perfectionist', condition: (u) => u.perfectScores > 0 }, ]; +// ── Pure helpers ────────────────────────────────────────────────────────────── + +/** + * Streak = consecutive completed weeks counting backwards from the most + * recent completed week. Does not require knowing the user's personal + * week number, so it works for every member in one pass. + */ +function computeStreak(heatmap) { + let last = -1; + for (let i = TOTAL_WEEKS - 1; i >= 0; i--) { + if (heatmap[i] !== null) { last = i; break; } + } + if (last === -1) return 0; + let streak = 0; + for (let i = last; i >= 0; i--) { + if (heatmap[i] !== null) streak++; + else break; + } + return streak; +} + +function timeAgo(dateStr) { + const diff = Date.now() - new Date(dateStr).getTime(); + const h = Math.floor(diff / 3_600_000); + if (h < 1) return 'just now'; + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d ago`; + return `${Math.floor(d / 7)}w ago`; +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +const SectionLabel = ({ text }) => ( +
+ // {text} +
+
+); + +const Stat = ({ value, label, icon, highlight = false }) => ( +
+ {icon} + + {value} + + {label} +
+); + +/** + * Single heatmap cell. Four intensity levels: + * 0 = not done (cream) 1 = done 2 = ≥70% 3 = 100% perfect + */ +const HeatCell = ({ score, week }) => { + const level = + score === null ? 0 : + score === 100 ? 3 : + score >= 70 ? 2 : 1; + + const bg = [ + 'bg-bg-warm', + 'bg-teal/25', + 'bg-teal/55', + 'bg-teal', + ][level]; + + return ( +
+ ); +}; + +const StatusPill = ({ streak, testsCompleted }) => { + if (testsCompleted === 0) + return ○ not started; + if (streak >= 4) + return ● on a roll 🔥; + if (streak >= 1) + return ● active; + return ◌ getting started; +}; + +// ── Main component ──────────────────────────────────────────────────────────── + const Leaderboard = () => { const { state } = useApp(); - const [board, setBoard] = useState([]); + const [users, setUsers] = useState([]); + const [feed, setFeed] = useState([]); + const [loaded, setLoaded] = useState(false); useEffect(() => { const load = async () => { - const [leaderboardData, allUsers] = await Promise.all([ + const [leaderboardData, allUsers, allResults] = await Promise.all([ db.getLeaderboard(), db.getTeamMembers(), + db.getAllTestResults(), ]); - const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id); - let data = leaderboardData.filter(entry => nonAdminIds.includes(entry.user_id)); + const nonAdmins = allUsers.filter(u => u.role !== 'admin'); + const nonAdminIds = new Set(nonAdmins.map(u => u.id)); - // Ensure all non-admin users appear even if they have no points yet - const inBoard = new Set(data.map(e => e.user_id)); - for (const user of allUsers) { - if (!inBoard.has(user.id) && user.role !== 'admin') { - data.push({ user_id: user.id, name: user.name, points: 0, tests_completed: 0, perfectScores: 0 }); - } + // Group test_results by user_id, keyed by week_number → percentage + const resultsByUser = {}; + for (const r of allResults) { + if (!nonAdminIds.has(r.user_id)) continue; + if (!resultsByUser[r.user_id]) resultsByUser[r.user_id] = {}; + resultsByUser[r.user_id][r.week_number] = r.percentage; } - // Compute perfect scores per user - data = await Promise.all(data.map(async entry => { - let perfectScores = 0; - for (let w = 1; w <= state.weekNumber; w++) { - const result = await db.getQuizResult(entry.user_id, w); - if (result && result.percentage === 100) perfectScores++; - } - return { ...entry, perfectScores }; - })); + // Build enriched user objects + const enriched = nonAdmins.map(user => { + const lb = leaderboardData.find(e => e.user_id === user.id) || {}; + const weekMap = resultsByUser[user.id] || {}; - data.sort((a, b) => b.points - a.points); - setBoard(data); + // 26-element array: index 0 = week 1. null = not done, number = percentage + const heatmap = Array.from({ length: TOTAL_WEEKS }, (_, i) => { + const pct = weekMap[i + 1]; + return pct !== undefined ? pct : null; + }); + + const streak = computeStreak(heatmap); + const perfectScores = heatmap.filter(s => s === 100).length; + + return { + id: user.id, + name: user.name, + points: lb.points || 0, + tests_completed: lb.tests_completed || 0, + heatmap, + streak, + perfectScores, + }; + }); + + // Sort: highest active streak first, then most tests, then name + enriched.sort((a, b) => + b.streak - a.streak || + b.tests_completed - a.tests_completed || + a.name.localeCompare(b.name) + ); + + // Recent activity feed: newest results across all non-admin users + const nameMap = Object.fromEntries(allUsers.map(u => [u.id, u.name])); + const recentFeed = allResults + .filter(r => nonAdminIds.has(r.user_id)) + .slice(0, 10) + .map(r => ({ + key: `${r.user_id}-${r.week_number}`, + name: nameMap[r.user_id] || 'Unknown', + week: r.week_number, + score: r.score, + total: r.total, + percentage: r.percentage, + created: r.created, + })); + + setUsers(enriched); + setFeed(recentFeed); + setLoaded(true); }; - load(); + load().catch(console.error); }, [state.weekNumber]); + const me = users.find(u => u.id === state.currentUser?.id); + const myWeek = state.weekNumber || 0; + + // The user with the longest active streak gets the ★ marker + const topStreakId = users.find(u => u.streak > 0)?.id; + return ( -
-
- -

Company Leaderboard

-

Compete, learn, and earn badges based on your weekly knowledge tests.

-
+
- {board.length >= 3 && ( -
- -
-
2
-
-
-

{board[1].name}

-

{board[1].points} pts

-
-
- - -
-
1
-
- -
-

{board[0].name}

-

{board[0].points} pts

-
-
- - -
-
3
-
-
-

{board[2].name}

-

{board[2].points} pts

-
-
-
- )} - - -
- {board.map((user, index) => { - const earnedBadges = BADGE_RULES.filter(r => r.condition(user)); - const isMe = user.user_id === state.currentUser?.id; - - return ( - -
- - #{index + 1} - -
-

- {user.name} - {isMe && You} -

-

- {user.tests_completed || 0} tests completed -

-
-
- -
-
- - {user.points} pts -
-
- -
- {earnedBadges.length > 0 ? ( - earnedBadges.map(b => ( -
- -
- )) - ) : ( - No badges yet - )} -
-
- ); - })} - - {board.length === 0 && ( -
- No one is on the leaderboard yet. Be the first to complete a test! -
+ {/* ── Page header ──────────────────────────────────────────────── */} +
+

+ // knowledge contributions +

+
+

Activity

+ {myWeek > 0 && ( + + branch: week-{myWeek}/26 + )}
- +
+ + {/* ── Your activity ─────────────────────────────────────────────── */} + {me && ( + + + + + {/* Name row */} +
+
+

{me.name}

+

+ @{me.name.toLowerCase().replace(/\s+/g, '.')} +

+
+ +
+ + {/* Heatmap */} +
+

+ 26-week contributions +

+
+ {me.heatmap.map((score, i) => ( + + ))} +
+ {/* Week labels */} +
+ wk 1 + wk 26 +
+
+ + {/* Stats */} +
+ } + /> + } + highlight={me.streak >= 3} + /> + } + highlight={me.perfectScores > 0} + /> +
+
+
+ )} + + {/* ── Team contributions ────────────────────────────────────────── */} +
+ + + + {loaded && users.length === 0 ? ( +

+ $ no contributions yet — start your first week +

+ ) : ( +
+ {users.map((user, i) => { + const isMe = user.id === state.currentUser?.id; + const badges = BADGE_RULES.filter(r => r.condition(user)); + const isTop = user.id === topStreakId; + const pct = Math.round((user.tests_completed / TOTAL_WEEKS) * 100); + + return ( + + {/* Name + meta row */} +
+
+ + {user.name} + + {isMe && ( + + (you) + + )} + {isTop && !isMe && ( + + ★ + + )} + {isTop && isMe && ( + + ★ + + )} +
+ +
+ + {user.tests_completed}/{TOTAL_WEEKS} + + 0 ? 'text-teal' : 'text-fg-subtle' + }`} + > + {user.streak > 0 ? `streak: ${user.streak}w` : '─ ─'} + +
+
+ + {/* Progress bar */} +
+ +
+ + {/* Badges */} + {badges.length > 0 && ( +
+ {badges.map(b => ( + + [{b.label}] + + ))} +
+ )} +
+ ); + })} +
+ )} +
+
+ + {/* ── Recent activity ───────────────────────────────────────────── */} + {feed.length > 0 && ( +
+ + + +
+ {feed.map((item, i) => ( + + + {item.name} + completed + week-{item.week} + + [{item.score}/{item.total} + {item.percentage === 100 && ( + + )} + ] + + + + {timeAgo(item.created)} + + + ))} +
+
+
+ )} +
); };