feat: add GraphControls component and useGraphData hook for knowledge graph management
This commit is contained in:
54
src/components/admin/graph/GraphControls.jsx
Normal file
54
src/components/admin/graph/GraphControls.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="mb-6 pb-4 border-b border-bg-warm space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showExcludeNodes}
|
||||||
|
onChange={e => onShowExcludeChange(e.target.checked)}
|
||||||
|
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
|
||||||
|
/>
|
||||||
|
Show Excluded Nodes (Reference Material)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={onAnalyze}
|
||||||
|
disabled={isAnalyzing || disabled}
|
||||||
|
className="w-full flex justify-center items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} />
|
||||||
|
{isAnalyzing ? 'Analyzing Graph…' : 'Analyze & Optimize Graph'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{analyzeError && (
|
||||||
|
<p className="mt-2 text-xs text-red-600 flex items-start gap-1">
|
||||||
|
<AlertCircle size={14} className="shrink-0 mt-0.5" />
|
||||||
|
{analyzeError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SuggestionsQueue onApplied={onApplied} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/hooks/useGraphData.js
Normal file
122
src/hooks/useGraphData.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,7 +53,15 @@ export async function upsertTopic(topic) {
|
|||||||
// ── Relations ────────────────────────────────────────────────────────────────
|
// ── Relations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getRelations() {
|
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) {
|
export async function saveRelations(relations) {
|
||||||
@@ -234,6 +242,17 @@ export async function getLeaderboard() {
|
|||||||
return entries.sort((a, b) => (b.points || 0) - (a.points || 0));
|
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) {
|
export async function upsertLeaderboardEntry(userId, name, pointsDelta, testsCompletedDelta = 0) {
|
||||||
try {
|
try {
|
||||||
const r = await pb.collection('leaderboard').getFirstListItem(`user_id="${userId}"`);
|
const r = await pb.collection('leaderboard').getFirstListItem(`user_id="${userId}"`);
|
||||||
|
|||||||
@@ -1,161 +1,415 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Trophy, Medal, Award, TrendingUp, Star, CheckSquare } from 'lucide-react';
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { GitCommitHorizontal, Zap, CheckCircle2, Clock } 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 { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TOTAL_WEEKS = 26;
|
||||||
|
|
||||||
const BADGE_RULES = [
|
const BADGE_RULES = [
|
||||||
{ id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.tests_completed > 0 },
|
{ id: 'first_test', label: 'first-steps', condition: (u) => u.tests_completed > 0 },
|
||||||
{ id: 'perfectionist', icon: Award, label: 'Perfectionist', condition: (u) => u.perfectScores > 0 },
|
{ id: 'veteran', label: 'veteran', condition: (u) => u.tests_completed >= 5 },
|
||||||
{ id: 'veteran', icon: Medal, 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 }) => (
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="font-mono text-xs text-fg-muted select-none">// {text}</span>
|
||||||
|
<div className="flex-1 h-px bg-bg-warm" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Stat = ({ value, label, icon, highlight = false }) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={highlight ? 'text-teal' : 'text-fg-muted'}>{icon}</span>
|
||||||
|
<span className={`font-mono text-sm font-bold ${highlight ? 'text-teal' : 'text-fg'}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-fg-subtle">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
title={score === null ? `Week ${week}: not done` : `Week ${week}: ${score}%`}
|
||||||
|
className={`w-2.5 h-2.5 rounded-[3px] ${bg} transition-colors duration-300`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusPill = ({ streak, testsCompleted }) => {
|
||||||
|
if (testsCompleted === 0)
|
||||||
|
return <Tag className="text-[10px] font-mono">○ not started</Tag>;
|
||||||
|
if (streak >= 4)
|
||||||
|
return <Tag variant="success" className="text-[10px] font-mono">● on a roll 🔥</Tag>;
|
||||||
|
if (streak >= 1)
|
||||||
|
return <Tag variant="success" className="text-[10px] font-mono">● active</Tag>;
|
||||||
|
return <Tag className="text-[10px] font-mono">◌ getting started</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const Leaderboard = () => {
|
const Leaderboard = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
const [board, setBoard] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
const [feed, setFeed] = useState([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [leaderboardData, allUsers] = await Promise.all([
|
const [leaderboardData, allUsers, allResults] = await Promise.all([
|
||||||
db.getLeaderboard(),
|
db.getLeaderboard(),
|
||||||
db.getTeamMembers(),
|
db.getTeamMembers(),
|
||||||
|
db.getAllTestResults(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
|
const nonAdmins = allUsers.filter(u => u.role !== 'admin');
|
||||||
let data = leaderboardData.filter(entry => nonAdminIds.includes(entry.user_id));
|
const nonAdminIds = new Set(nonAdmins.map(u => u.id));
|
||||||
|
|
||||||
// Ensure all non-admin users appear even if they have no points yet
|
// Group test_results by user_id, keyed by week_number → percentage
|
||||||
const inBoard = new Set(data.map(e => e.user_id));
|
const resultsByUser = {};
|
||||||
for (const user of allUsers) {
|
for (const r of allResults) {
|
||||||
if (!inBoard.has(user.id) && user.role !== 'admin') {
|
if (!nonAdminIds.has(r.user_id)) continue;
|
||||||
data.push({ user_id: user.id, name: user.name, points: 0, tests_completed: 0, perfectScores: 0 });
|
if (!resultsByUser[r.user_id]) resultsByUser[r.user_id] = {};
|
||||||
}
|
resultsByUser[r.user_id][r.week_number] = r.percentage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute perfect scores per user
|
// Build enriched user objects
|
||||||
data = await Promise.all(data.map(async entry => {
|
const enriched = nonAdmins.map(user => {
|
||||||
let perfectScores = 0;
|
const lb = leaderboardData.find(e => e.user_id === user.id) || {};
|
||||||
for (let w = 1; w <= state.weekNumber; w++) {
|
const weekMap = resultsByUser[user.id] || {};
|
||||||
const result = await db.getQuizResult(entry.user_id, w);
|
|
||||||
if (result && result.percentage === 100) perfectScores++;
|
|
||||||
}
|
|
||||||
return { ...entry, perfectScores };
|
|
||||||
}));
|
|
||||||
|
|
||||||
data.sort((a, b) => b.points - a.points);
|
// 26-element array: index 0 = week 1. null = not done, number = percentage
|
||||||
setBoard(data);
|
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]);
|
}, [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 (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
<div className="p-4 md:p-8 max-w-3xl mx-auto pb-24 md:pb-8">
|
||||||
<div className="text-center mb-12">
|
|
||||||
<Trophy size={56} className="mx-auto text-teal/30 mb-4" />
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Company Leaderboard</h1>
|
|
||||||
<p className="text-fg-muted">Compete, learn, and earn badges based on your weekly knowledge tests.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{board.length >= 3 && (
|
{/* ── Page header ──────────────────────────────────────────────── */}
|
||||||
<div className="flex justify-center items-end gap-2 md:gap-6 mb-16 h-64 mt-12">
|
<div className="mb-10">
|
||||||
<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">
|
<p className="font-mono text-xs text-fg-muted mb-1 select-none">
|
||||||
<div className="relative mb-4">
|
// knowledge contributions
|
||||||
<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>
|
</p>
|
||||||
<div className="absolute -inset-2 bg-slate-100 rounded-full z-0 blur-sm"></div>
|
<div className="flex items-baseline gap-3 flex-wrap">
|
||||||
</div>
|
<h1 className="text-2xl md:text-3xl font-bold text-teal">Activity</h1>
|
||||||
<p className="font-bold text-center truncate w-full px-2">{board[1].name}</p>
|
{myWeek > 0 && (
|
||||||
<p className="text-teal font-medium text-sm">{board[1].points} pts</p>
|
<span className="font-mono text-xs text-fg-muted bg-bg-warm px-2 py-1 rounded-[var(--r-sm)]">
|
||||||
<div className="w-full h-24 bg-gradient-to-t from-slate-200/50 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
|
branch: week-{myWeek}/26
|
||||||
</motion.div>
|
</span>
|
||||||
|
|
||||||
<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="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="absolute -inset-3 bg-yellow-300 rounded-full z-0 blur-md opacity-60"></div>
|
|
||||||
<Trophy size={24} className="absolute -top-6 left-1/2 -translate-x-1/2 text-yellow-500" />
|
|
||||||
</div>
|
|
||||||
<p className="font-bold text-center text-lg truncate w-full px-2">{board[0].name}</p>
|
|
||||||
<p className="text-teal font-bold">{board[0].points} pts</p>
|
|
||||||
<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 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="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="absolute -inset-2 bg-amber-800/20 rounded-full z-0 blur-sm"></div>
|
|
||||||
</div>
|
|
||||||
<p className="font-bold text-center truncate w-full px-2">{board[2].name}</p>
|
|
||||||
<p className="text-teal font-medium text-sm">{board[2].points} pts</p>
|
|
||||||
<div className="w-full h-20 bg-gradient-to-t from-amber-700/10 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="border border-bg-warm p-0 overflow-hidden">
|
|
||||||
<div className="divide-y divide-bg-warm">
|
|
||||||
{board.map((user, index) => {
|
|
||||||
const earnedBadges = BADGE_RULES.filter(r => r.condition(user));
|
|
||||||
const isMe = user.user_id === state.currentUser?.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: index * 0.05 }}
|
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 min-w-[200px]">
|
|
||||||
<span className={`font-mono text-lg font-bold w-6 text-center ${
|
|
||||||
index === 0 ? 'text-yellow-500' :
|
|
||||||
index === 1 ? 'text-slate-400' :
|
|
||||||
index === 2 ? 'text-amber-700' : 'text-fg-muted'
|
|
||||||
}`}>
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold flex items-center gap-2">
|
|
||||||
{user.name}
|
|
||||||
{isMe && <Tag variant="accent" className="text-[10px]">You</Tag>}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-fg-muted flex items-center gap-1">
|
|
||||||
<CheckSquare size={12} /> {user.tests_completed || 0} tests completed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<TrendingUp size={14} />
|
|
||||||
{user.points} pts
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 sm:justify-end min-w-[120px]">
|
|
||||||
{earnedBadges.length > 0 ? (
|
|
||||||
earnedBadges.map(b => (
|
|
||||||
<div key={b.id} title={b.label} className="w-8 h-8 rounded-full bg-bg-warm flex items-center justify-center text-teal shadow-sm border border-bg-warm">
|
|
||||||
<b.icon size={14} />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-fg-muted italic">No badges yet</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{board.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-fg-muted">
|
|
||||||
No one is on the leaderboard yet. Be the first to complete a test!
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Your activity ─────────────────────────────────────────────── */}
|
||||||
|
{me && (
|
||||||
|
<motion.section
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<SectionLabel text="your activity" />
|
||||||
|
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
{/* Name row */}
|
||||||
|
<div className="flex items-start justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-lg text-teal leading-tight">{me.name}</p>
|
||||||
|
<p className="font-mono text-[11px] text-fg-muted">
|
||||||
|
@{me.name.toLowerCase().replace(/\s+/g, '.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill streak={me.streak} testsCompleted={me.tests_completed} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heatmap */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="font-mono text-[9px] text-fg-subtle uppercase tracking-widest mb-2 select-none">
|
||||||
|
26-week contributions
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-[3px]">
|
||||||
|
{me.heatmap.map((score, i) => (
|
||||||
|
<HeatCell key={i} score={score} week={i + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Week labels */}
|
||||||
|
<div className="flex justify-between mt-1.5">
|
||||||
|
<span className="font-mono text-[9px] text-fg-subtle">wk 1</span>
|
||||||
|
<span className="font-mono text-[9px] text-fg-subtle">wk 26</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex gap-5 pt-3 border-t border-bg-warm flex-wrap">
|
||||||
|
<Stat
|
||||||
|
value={me.points}
|
||||||
|
label="commits"
|
||||||
|
icon={<GitCommitHorizontal size={12} />}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
value={`${me.streak}w`}
|
||||||
|
label="streak"
|
||||||
|
icon={<Zap size={12} />}
|
||||||
|
highlight={me.streak >= 3}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
value={me.perfectScores}
|
||||||
|
label="perfect"
|
||||||
|
icon={<CheckCircle2 size={12} />}
|
||||||
|
highlight={me.perfectScores > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Team contributions ────────────────────────────────────────── */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<SectionLabel text="team contributions" />
|
||||||
|
|
||||||
|
<Card className="border border-bg-warm p-0 overflow-hidden">
|
||||||
|
{loaded && users.length === 0 ? (
|
||||||
|
<p className="p-8 text-center font-mono text-sm text-fg-muted">
|
||||||
|
$ no contributions yet — start your first week
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-bg-warm">
|
||||||
|
{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 (
|
||||||
|
<motion.div
|
||||||
|
key={user.id}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.04 }}
|
||||||
|
className={[
|
||||||
|
'px-5 py-4',
|
||||||
|
isMe
|
||||||
|
? 'bg-teal/5 border-l-4 border-teal'
|
||||||
|
: 'hover:bg-bg-warm/40 transition-colors',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{/* Name + meta row */}
|
||||||
|
<div className="flex items-center justify-between mb-2.5 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="font-mono text-sm font-medium text-fg truncate">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
{isMe && (
|
||||||
|
<span className="font-mono text-[10px] text-teal/70 flex-shrink-0">
|
||||||
|
(you)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isTop && !isMe && (
|
||||||
|
<span
|
||||||
|
title="Longest active streak"
|
||||||
|
className="text-amber-400 text-xs flex-shrink-0"
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isTop && isMe && (
|
||||||
|
<span
|
||||||
|
title="Longest active streak"
|
||||||
|
className="text-amber-400 text-xs flex-shrink-0"
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span className="font-mono text-[11px] text-fg-muted">
|
||||||
|
{user.tests_completed}/{TOTAL_WEEKS}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[11px] ${
|
||||||
|
user.streak > 0 ? 'text-teal' : 'text-fg-subtle'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.streak > 0 ? `streak: ${user.streak}w` : '─ ─'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1 bg-bg-warm rounded-full overflow-hidden mb-2.5">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.7, delay: i * 0.04 + 0.1, ease: 'easeOut' }}
|
||||||
|
className={`h-full rounded-full ${isMe ? 'bg-teal' : 'bg-teal/45'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
{badges.length > 0 && (
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{badges.map(b => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
className="font-mono text-[10px] bg-teal/10 text-teal px-1.5 py-0.5 rounded"
|
||||||
|
>
|
||||||
|
[{b.label}]
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Recent activity ───────────────────────────────────────────── */}
|
||||||
|
{feed.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<SectionLabel text="recent activity" />
|
||||||
|
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{feed.map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.key}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: i * 0.03 }}
|
||||||
|
className="flex items-baseline gap-2 font-mono text-xs"
|
||||||
|
>
|
||||||
|
<span className="text-teal/40 flex-shrink-0 select-none">›</span>
|
||||||
|
<span className="text-fg font-medium flex-shrink-0">{item.name}</span>
|
||||||
|
<span className="text-fg-subtle">completed</span>
|
||||||
|
<span className="text-teal font-medium">week-{item.week}</span>
|
||||||
|
<span className="text-fg-subtle">
|
||||||
|
[{item.score}/{item.total}
|
||||||
|
{item.percentage === 100 && (
|
||||||
|
<span className="text-amber-400"> ★</span>
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex-shrink-0 flex items-center gap-1 text-fg-subtle">
|
||||||
|
<Clock size={10} />
|
||||||
|
{timeAgo(item.created)}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user