diff --git a/.github/workflows/mentor-booking.yml b/.github/workflows/mentor-booking.yml new file mode 100644 index 00000000..f5c6a5b4 --- /dev/null +++ b/.github/workflows/mentor-booking.yml @@ -0,0 +1,34 @@ +name: Mentor Booking Feature Checks + +on: + pull_request: + paths: + - 'frontend/src/lib/mentor-booking.ts' + - 'frontend/src/lib/__tests__/mentor-booking.test.ts' + - '.github/workflows/mentor-booking.yml' + push: + branches: [main, master] + paths: + - 'frontend/src/lib/mentor-booking.ts' + - 'frontend/src/lib/__tests__/mentor-booking.test.ts' + - '.github/workflows/mentor-booking.yml' + +jobs: + mentor-booking: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Run mentor booking tests + working-directory: frontend + run: pnpm exec vitest run src/lib/__tests__/mentor-booking.test.ts diff --git a/docs/frontend/PLATFORM_FEATURE_OPTIMIZATIONS.md b/docs/frontend/PLATFORM_FEATURE_OPTIMIZATIONS.md new file mode 100644 index 00000000..b5ebf114 --- /dev/null +++ b/docs/frontend/PLATFORM_FEATURE_OPTIMIZATIONS.md @@ -0,0 +1,60 @@ +# Platform Feature Optimizations + +This document covers the MVP frontend/DevOps additions for: + +- Performance Profiling in the Open Source Contribution Trainer +- Optimized Merkle Tree Builder +- Consensus Algorithm Sandbox in the Web3 Learning Roadmap +- Mentor Booking optimization checks + +## Open Source Contribution Performance Profiling + +Core logic lives in `frontend/src/lib/contribution-performance.ts`. + +The profiler accepts timestamped contribution events such as issue assignment, PR open, review request, review received, change requests, and merge. It derives: + +- issue-to-merge cycle time +- review response time +- throughput, quality, focus, and overall scores +- bottlenecks and recommendations + +The UI panel is rendered on `/performance-metrics` through `ContributionPerformanceProfiler`. + +## Optimized Merkle Tree Builder + +Core logic lives in `frontend/src/lib/merkle-tree-builder.ts`. + +The builder normalizes leaves, removes duplicates, builds levels iteratively, duplicates odd leaves deterministically, and exposes proof generation plus proof verification. The `/merkle-tree` page now uses this shared logic for visualization and validation path display. + +## Consensus Algorithm Sandbox + +Core logic lives in `frontend/src/lib/consensus-sandbox.ts`. + +The sandbox supports: + +- proof of work leader selection by hash power +- proof of stake leader selection by active stake +- federated Byzantine agreement selection by quorum-slice trust overlap + +The sandbox is embedded on `/roadmap` next to the learning path selector. + +## Mentor Booking Optimization + +Core logic lives in `frontend/src/lib/mentor-booking.ts`. + +It provides deterministic slot selection based on tag match, capacity, fill ratio, and start time. It also prevents overbooking and detects overlapping slots per mentor. + +CI coverage is isolated in `.github/workflows/mentor-booking.yml`, which runs mentor booking tests when relevant files change. + +## Tests + +Run the focused tests: + +```bash +cd frontend +pnpm vitest run \ + src/lib/__tests__/contribution-performance.test.ts \ + src/lib/__tests__/merkle-tree-builder.test.ts \ + src/lib/__tests__/consensus-sandbox.test.ts \ + src/lib/__tests__/mentor-booking.test.ts +``` diff --git a/frontend/src/app/merkle-tree/page.tsx b/frontend/src/app/merkle-tree/page.tsx index 1d66a193..4ebea52b 100644 --- a/frontend/src/app/merkle-tree/page.tsx +++ b/frontend/src/app/merkle-tree/page.tsx @@ -2,7 +2,12 @@ import * as d3 from 'd3'; import { Plus, Trash2, TreeDeciduous } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + buildOptimizedMerkleTree, + getMerkleProof, + stableHash, +} from '@/lib/merkle-tree-builder'; interface MerkleNode { id: string; @@ -25,6 +30,7 @@ export default function MerkleTreePage() { const [selectedAddress, setSelectedAddress] = useState(null); const [validationPath, setValidationPath] = useState([]); const svgRef = useRef(null); + const merkleResult = useMemo(() => buildOptimizedMerkleTree(addresses), [addresses]); const simpleHash = (input: string): string => { let hash = 0; @@ -138,7 +144,9 @@ export default function MerkleTreePage() { const nodeRadius = 25; // Convert tree to d3 hierarchy - const rootD3 = d3.hierarchy(root); + const rootD3 = d3.hierarchy(root, (node: any) => + [node.left, node.right].filter(Boolean) + ); // Create tree layout const treeLayout = d3.tree().size([width - 100, height - 100]); @@ -173,7 +181,8 @@ export default function MerkleTreePage() { .attr('r', nodeRadius) .attr('fill', (d: any) => { if (d.data.isLeaf) { - return d.data.address === selectedAddress ? '#22c55e' : '#ef4444'; + const leafValue = d.data.address ?? d.data.value; + return leafValue === selectedAddress ? '#22c55e' : '#ef4444'; } return '#3b82f6'; }) @@ -191,10 +200,14 @@ export default function MerkleTreePage() { }) .style('cursor', 'pointer') .on('click', (event: any, d: any) => { - if (d.data.isLeaf && d.data.address) { - setSelectedAddress(d.data.address); - const tree = buildMerkleTree(addresses); - setValidationPath(findValidationPath(tree, d.data.address)); + const leafValue = d.data.address ?? d.data.value; + if (d.data.isLeaf && leafValue) { + setSelectedAddress(leafValue); + setValidationPath([ + stableHash(leafValue), + ...getMerkleProof(merkleResult, leafValue).map((step) => step.hash), + merkleResult.root.hash, + ]); } }); @@ -215,15 +228,14 @@ export default function MerkleTreePage() { .attr('fill', '#ffffff') .attr('font-size', '9px') .attr('font-family', 'monospace') - .text((d: any) => d.data.address); + .text((d: any) => d.data.address ?? d.data.value); }; useEffect(() => { if (addresses.length > 0) { - const tree = buildMerkleTree(addresses); - renderTree(tree); + renderTree(merkleResult.root as MerkleNode); } - }, [addresses, selectedAddress, validationPath]); + }, [addresses, selectedAddress, validationPath, merkleResult]); const addAddress = () => { const newAddress = `0x${Math.random().toString(16).substring(2, 6)}...${Math.random().toString(16).substring(2, 6)}`; @@ -240,8 +252,11 @@ export default function MerkleTreePage() { const verifyAddress = (address: string) => { setSelectedAddress(address); - const tree = buildMerkleTree(addresses); - setValidationPath(findValidationPath(tree, address)); + setValidationPath([ + stableHash(address), + ...getMerkleProof(merkleResult, address).map((step) => step.hash), + merkleResult.root.hash, + ]); }; return ( @@ -278,6 +293,11 @@ export default function MerkleTreePage() {
+
+ + Root {merkleResult.root.hash} · {merkleResult.leafCount} Leaves · Depth {merkleResult.depth} + +
Leaf Node diff --git a/frontend/src/app/performance-metrics/page.tsx b/frontend/src/app/performance-metrics/page.tsx index ff2a3d08..9a16a32c 100644 --- a/frontend/src/app/performance-metrics/page.tsx +++ b/frontend/src/app/performance-metrics/page.tsx @@ -3,6 +3,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { usePerformanceMetrics } from '@/hooks/usePerformanceMetrics'; import PerformanceMetricsDashboard from '@/components/performance-metrics/PerformanceMetricsDashboard'; +import ContributionPerformanceProfiler from '@/components/performance-metrics/ContributionPerformanceProfiler'; import Link from 'next/link'; import { ArrowLeft } from 'lucide-react'; import { motion } from 'framer-motion'; @@ -84,6 +85,9 @@ export default function PerformanceMetricsPage() { error={error} isFallback={isFallback} /> +
+ +
diff --git a/frontend/src/app/roadmap/page.tsx b/frontend/src/app/roadmap/page.tsx index ce7cff51..bcd434e1 100644 --- a/frontend/src/app/roadmap/page.tsx +++ b/frontend/src/app/roadmap/page.tsx @@ -1,325 +1,132 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { DatabaseManager, RoadmapNodeRecord } from '@/lib/storage/DatabaseManager'; - -const DEFAULT_NODES = [ - { - id: 1, - title: 'Foundations', - desc: 'Ledger basics, accounts, and trustlines.', - status: 'COMPLETED', - x: '50%', - y: '10%', - }, - { - id: 2, - title: 'Assets & SDEX', - desc: 'Issuing tokens and liquidity pools.', - status: 'IN_PROGRESS', - x: '30%', - y: '35%', - }, - { - id: 3, - title: 'Soroban 101', - desc: 'Rust smart contracts and WASM.', - status: 'LOCKED', - x: '70%', - y: '35%', - }, - { - id: 4, - title: 'Advanced DeFi', - desc: 'Flash loans and cross-chain hooks.', - status: 'LOCKED', - x: '50%', - y: '60%', - }, - { - id: 5, - title: 'Protocol Expert', - desc: 'Core architecture and consensus.', - status: 'LOCKED', - x: '50%', - y: '85%', - }, -]; - -export default function RoadmapPage() { - const [nodes, setNodes] = useState(DEFAULT_NODES); - const [activeNode, setActiveNode] = useState(DEFAULT_NODES[1]); - const [db] = useState(() => new DatabaseManager()); - const [isInitializing, setIsInitializing] = useState(true); - - useEffect(() => { - const initDb = async () => { - try { - const storedNodes = await db.listRoadmapNodes(); - if (storedNodes.length > 0) { - // Merge stored status with default nodes - const mergedNodes = DEFAULT_NODES.map(defaultNode => { - const stored = storedNodes.find(n => n.id === defaultNode.id); - return stored ? { ...defaultNode, status: stored.status } : defaultNode; - }); - setNodes(mergedNodes); - - // Set active node to the first in-progress node, or the stored node 2 - const inProgressNode = mergedNodes.find(n => n.status === 'IN_PROGRESS'); - if (inProgressNode) setActiveNode(inProgressNode); - } else { - // Initialize DB with default nodes - await Promise.all(DEFAULT_NODES.map(node => - db.upsertRoadmapNode({ id: node.id, status: node.status, updatedAt: Date.now() }) - )); - } - } catch (error) { - console.error('Failed to load roadmap from DB:', error); - } finally { - setIsInitializing(false); - } - }; - initDb(); - }, [db]); - - const handleNodeAction = async () => { - if (activeNode.status === 'IN_PROGRESS') { - const updatedNode = { ...activeNode, status: 'COMPLETED' }; - const newNodes = nodes.map(n => n.id === activeNode.id ? updatedNode : n); - setNodes(newNodes); - setActiveNode(updatedNode); - await db.upsertRoadmapNode({ id: activeNode.id, status: 'COMPLETED', updatedAt: Date.now() }); - } - }; - - if (isInitializing) { - return
Loading Roadmap Index...
; - } -import { useState, useEffect, useMemo } from 'react'; +import { useMemo, useState } from 'react'; +import { Cpu, Map, MapPin } from 'lucide-react'; import { RoadmapView } from '@/components/roadmap'; -import { coursesAPI } from '@/lib/api'; +import { courses as curriculumCourses } from '@/app/curriculum-data'; +import { + DEFAULT_CONSENSUS_NODES, + runConsensusRound, + type ConsensusAlgorithm, +} from '@/lib/consensus-sandbox'; import type { Course } from '@/lib/api'; -import { Skeleton } from '@/components/common/Skeleton'; -import { Map, MapPin } from 'lucide-react'; export default function RoadmapPage() { - const [courses, setCourses] = useState([]); - const [selectedCourseId, setSelectedCourseId] = useState( - null - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let mounted = true; - - async function loadCourses() { - try { - const data = await coursesAPI.getAll(); - if (!mounted) return; - setCourses(data); - if (data.length > 0) { - setSelectedCourseId(data[0]!.id); - } - } catch (err) { - if (!mounted) return; - setError( - err instanceof Error ? err.message : 'Failed to load courses' - ); - } finally { - if (mounted) setLoading(false); - } - } - - loadCourses(); - return () => { - mounted = false; - }; - }, []); - + const [selectedCourseId, setSelectedCourseId] = useState(curriculumCourses[0]!.id); + const [algorithm, setAlgorithm] = useState('fba'); const selectedCourse = useMemo( - () => courses.find((c) => c.id === selectedCourseId) ?? null, - [courses, selectedCourseId] + () => curriculumCourses.find((course) => course.id === selectedCourseId) ?? curriculumCourses[0]!, + [selectedCourseId] + ); + const roadmapCourse = useMemo( + () => ({ + id: selectedCourse.id, + title: selectedCourse.title, + description: selectedCourse.description, + instructor: 'Web3 Student Lab', + credits: selectedCourse.lessons.length, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }), + [selectedCourse] + ); + const consensus = useMemo( + () => runConsensusRound(algorithm, DEFAULT_CONSENSUS_NODES), + [algorithm] ); return ( -
- {/* Abstract Background Glows */} -
- -
-
-
- +
+
+ +
+
+
+ Learning Trajectory
-

- INTERACTIVE
ROADMAP +

+ Interactive
+ + Roadmap +

-

- Module Hierarchy & Skill Acquisition Tree (Indexed) +

+ Visualize your learning path and experiment with consensus models used by decentralized networks.

-
- {/* Connecting Lines (SVG) */} - - - - - - - - - {/* Nodes */} - {nodes.map((node) => ( - -
- ) : ( - <> -
- -
- -
- - - + {option} + + ))} +
+
+
+
+

Leader

+

{consensus.leaderId ?? 'None'}

+
+
+

Agreement

+

{consensus.agreementPercent}%

+

{consensus.explanation}

+

+ {consensus.finalized ? 'Finalized' : 'Not finalized'} +

+ +
-
- -
- - )} +
+ +
); diff --git a/frontend/src/components/performance-metrics/ContributionPerformanceProfiler.tsx b/frontend/src/components/performance-metrics/ContributionPerformanceProfiler.tsx new file mode 100644 index 00000000..a00d563e --- /dev/null +++ b/frontend/src/components/performance-metrics/ContributionPerformanceProfiler.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { + SAMPLE_CONTRIBUTION_EVENTS, + buildContributionPerformanceProfile, +} from '@/lib/contribution-performance'; + +export default function ContributionPerformanceProfiler() { + const profile = buildContributionPerformanceProfile(SAMPLE_CONTRIBUTION_EVENTS, 0.95); + const metrics = [ + ['Cycle Time', `${profile.cycleTimeHours}h`], + ['Review Response', `${profile.reviewResponseHours}h`], + ['Overall Score', `${profile.overallScore}/100`], + ]; + + return ( +
+
+

+ Open Source Contribution Trainer +

+

+ Performance Profiling +

+
+ +
+ {metrics.map(([label, value]) => ( +
+

+ {label} +

+

{value}

+
+ ))} +
+ +
+
+

Recommendations

+
    + {profile.recommendations.map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+

Bottlenecks

+
+ {profile.bottlenecks.length > 0 ? profile.bottlenecks.join(', ') : 'No active bottlenecks detected.'} +
+
+
+
+ ); +} diff --git a/frontend/src/lib/__tests__/consensus-sandbox.test.ts b/frontend/src/lib/__tests__/consensus-sandbox.test.ts new file mode 100644 index 00000000..8174a7e7 --- /dev/null +++ b/frontend/src/lib/__tests__/consensus-sandbox.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_CONSENSUS_NODES, runConsensusRound } from '../consensus-sandbox'; + +describe('consensus sandbox', () => { + it('selects proof-of-work leader by hash power', () => { + const result = runConsensusRound('pow', DEFAULT_CONSENSUS_NODES); + + expect(result.leaderId).toBe('bob'); + expect(result.finalized).toBe(true); + }); + + it('selects proof-of-stake leader by active stake', () => { + const result = runConsensusRound('pos', DEFAULT_CONSENSUS_NODES); + + expect(result.leaderId).toBe('alice'); + expect(result.agreementPercent).toBe(40); + }); + + it('requires quorum overlap for federated voting', () => { + const result = runConsensusRound('fba', DEFAULT_CONSENSUS_NODES); + + expect(result.leaderId).toBe('alice'); + expect(result.agreementPercent).toBeGreaterThanOrEqual(50); + }); + + it('does not finalize when every validator is offline', () => { + const result = runConsensusRound( + 'pos', + DEFAULT_CONSENSUS_NODES.map((node) => ({ ...node, online: false })) + ); + + expect(result.finalized).toBe(false); + expect(result.leaderId).toBeNull(); + }); +}); diff --git a/frontend/src/lib/__tests__/contribution-performance.test.ts b/frontend/src/lib/__tests__/contribution-performance.test.ts new file mode 100644 index 00000000..f0c635d9 --- /dev/null +++ b/frontend/src/lib/__tests__/contribution-performance.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + SAMPLE_CONTRIBUTION_EVENTS, + buildContributionPerformanceProfile, + type ContributionEvent, +} from '../contribution-performance'; + +describe('contribution performance profiling', () => { + it('builds a healthy open source contribution profile', () => { + const profile = buildContributionPerformanceProfile(SAMPLE_CONTRIBUTION_EVENTS, 0.95); + + expect(profile.cycleTimeHours).toBe(25); + expect(profile.reviewResponseHours).toBe(3.9); + expect(profile.overallScore).toBeGreaterThan(80); + expect(profile.bottlenecks).toEqual([]); + }); + + it('flags slow reviews and repeated changes', () => { + const events: ContributionEvent[] = [ + { id: '1', type: 'issue_assigned', timestamp: '2026-06-20T09:00:00.000Z' }, + { id: '2', type: 'review_requested', timestamp: '2026-06-20T10:00:00.000Z' }, + { id: '3', type: 'changes_requested', timestamp: '2026-06-21T10:00:00.000Z' }, + { id: '4', type: 'changes_requested', timestamp: '2026-06-22T10:00:00.000Z' }, + { id: '5', type: 'review_received', timestamp: '2026-06-22T12:00:00.000Z' }, + { id: '6', type: 'pr_merged', timestamp: '2026-06-23T12:00:00.000Z' }, + ]; + + const profile = buildContributionPerformanceProfile(events, 0.7); + + expect(profile.bottlenecks).toContain('Slow review response'); + expect(profile.bottlenecks).toContain('Repeated change requests'); + expect(profile.qualityScore).toBeLessThan(80); + }); +}); diff --git a/frontend/src/lib/__tests__/mentor-booking.test.ts b/frontend/src/lib/__tests__/mentor-booking.test.ts new file mode 100644 index 00000000..fcc5c107 --- /dev/null +++ b/frontend/src/lib/__tests__/mentor-booking.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + detectMentorConflicts, + findBestMentorSlot, + reserveMentorSlot, + type MentorSlot, +} from '../mentor-booking'; + +const slots: MentorSlot[] = [ + { + id: 'slot-1', + mentorId: 'mentor-a', + startsAt: '2026-06-29T10:00:00.000Z', + endsAt: '2026-06-29T10:30:00.000Z', + capacity: 2, + booked: 1, + tags: ['git', 'pull-request'], + }, + { + id: 'slot-2', + mentorId: 'mentor-b', + startsAt: '2026-06-29T09:00:00.000Z', + endsAt: '2026-06-29T09:30:00.000Z', + capacity: 1, + booked: 0, + tags: ['solidity'], + }, +]; + +describe('mentor booking optimization', () => { + it('selects the best available slot by tag match and earliest time', () => { + const slot = findBestMentorSlot(slots, { + learnerId: 'learner-1', + preferredTags: ['pull-request'], + earliestAt: '2026-06-29T08:00:00.000Z', + }); + + expect(slot?.id).toBe('slot-1'); + }); + + it('reserves capacity and rejects overbooking', () => { + const reserved = reserveMentorSlot(slots, 'slot-1'); + + expect(reserved.find((slot) => slot.id === 'slot-1')?.booked).toBe(2); + expect(() => reserveMentorSlot(reserved, 'slot-1')).toThrow('Mentor slot is full'); + }); + + it('detects overlapping mentor slots', () => { + expect( + detectMentorConflicts([ + ...slots, + { + id: 'slot-3', + mentorId: 'mentor-a', + startsAt: '2026-06-29T10:15:00.000Z', + endsAt: '2026-06-29T10:45:00.000Z', + capacity: 1, + booked: 0, + tags: ['git'], + }, + ]) + ).toEqual(['mentor-a:slot-1:slot-3']); + }); +}); diff --git a/frontend/src/lib/__tests__/merkle-tree-builder.test.ts b/frontend/src/lib/__tests__/merkle-tree-builder.test.ts new file mode 100644 index 00000000..4da11534 --- /dev/null +++ b/frontend/src/lib/__tests__/merkle-tree-builder.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + buildOptimizedMerkleTree, + getMerkleProof, + normalizeMerkleLeaves, + verifyMerkleProof, +} from '../merkle-tree-builder'; + +describe('optimized Merkle tree builder', () => { + it('normalizes blank and duplicate leaves', () => { + expect(normalizeMerkleLeaves([' alice ', '', 'ALICE', 'bob'])).toEqual(['alice', 'bob']); + }); + + it('builds a deterministic tree with proof verification', () => { + const tree = buildOptimizedMerkleTree(['alice', 'bob', 'carol', 'drew']); + const proof = getMerkleProof(tree, 'carol'); + + expect(tree.root.hash).toHaveLength(8); + expect(tree.leafCount).toBe(4); + expect(tree.depth).toBe(2); + expect(verifyMerkleProof('carol', proof, tree.root.hash)).toBe(true); + expect(verifyMerkleProof('mallory', proof, tree.root.hash)).toBe(false); + }); + + it('duplicates the last leaf for odd layers', () => { + const tree = buildOptimizedMerkleTree(['alice', 'bob', 'carol']); + + expect(tree.duplicateLeafCount).toBe(1); + expect(getMerkleProof(tree, 'carol')).toHaveLength(2); + }); +}); diff --git a/frontend/src/lib/consensus-sandbox.ts b/frontend/src/lib/consensus-sandbox.ts new file mode 100644 index 00000000..0c52076f --- /dev/null +++ b/frontend/src/lib/consensus-sandbox.ts @@ -0,0 +1,86 @@ +export type ConsensusAlgorithm = 'pow' | 'pos' | 'fba'; + +export interface ConsensusNode { + id: string; + stake: number; + hashPower: number; + trustedBy: string[]; + online: boolean; +} + +export interface ConsensusResult { + algorithm: ConsensusAlgorithm; + leaderId: string | null; + agreementPercent: number; + finalized: boolean; + explanation: string; +} + +export const DEFAULT_CONSENSUS_NODES: ConsensusNode[] = [ + { id: 'alice', stake: 40, hashPower: 12, trustedBy: ['bob', 'carol'], online: true }, + { id: 'bob', stake: 25, hashPower: 20, trustedBy: ['alice', 'drew'], online: true }, + { id: 'carol', stake: 20, hashPower: 8, trustedBy: ['alice', 'bob'], online: true }, + { id: 'drew', stake: 15, hashPower: 5, trustedBy: ['alice'], online: true }, +]; + +export function runConsensusRound( + algorithm: ConsensusAlgorithm, + nodes: ConsensusNode[] = DEFAULT_CONSENSUS_NODES +): ConsensusResult { + const online = nodes.filter((node) => node.online); + if (online.length === 0) { + return { + algorithm, + leaderId: null, + agreementPercent: 0, + finalized: false, + explanation: 'No online validators are available.', + }; + } + + if (algorithm === 'pow') { + const leader = [...online].sort((a, b) => b.hashPower - a.hashPower)[0]!; + const total = online.reduce((sum, node) => sum + node.hashPower, 0); + const agreementPercent = Math.round((leader.hashPower / total) * 100); + return { + algorithm, + leaderId: leader.id, + agreementPercent, + finalized: agreementPercent >= 35, + explanation: `${leader.id} wins by contributing the most hash power.`, + }; + } + + if (algorithm === 'pos') { + const leader = [...online].sort((a, b) => b.stake - a.stake)[0]!; + const total = online.reduce((sum, node) => sum + node.stake, 0); + const agreementPercent = Math.round((leader.stake / total) * 100); + return { + algorithm, + leaderId: leader.id, + agreementPercent, + finalized: agreementPercent >= 34, + explanation: `${leader.id} leads because they hold the largest active stake.`, + }; + } + + const trustCounts = new Map(); + for (const node of online) { + for (const trusted of node.trustedBy) { + if (online.some((candidate) => candidate.id === trusted)) { + trustCounts.set(trusted, (trustCounts.get(trusted) ?? 0) + 1); + } + } + } + const [leaderId, votes = 0] = [...trustCounts.entries()].sort((a, b) => b[1] - a[1])[0] ?? []; + const agreementPercent = Math.round((votes / online.length) * 100); + return { + algorithm, + leaderId: leaderId ?? null, + agreementPercent, + finalized: agreementPercent >= 67, + explanation: leaderId + ? `${leaderId} is selected by quorum-slice trust overlap.` + : 'No trusted quorum overlap was found.', + }; +} diff --git a/frontend/src/lib/contribution-performance.ts b/frontend/src/lib/contribution-performance.ts new file mode 100644 index 00000000..ec2e4543 --- /dev/null +++ b/frontend/src/lib/contribution-performance.ts @@ -0,0 +1,94 @@ +export type ContributionEventType = + | 'issue_assigned' + | 'branch_created' + | 'commit_pushed' + | 'pr_opened' + | 'review_requested' + | 'review_received' + | 'changes_requested' + | 'pr_merged'; + +export interface ContributionEvent { + id: string; + type: ContributionEventType; + timestamp: string; +} + +export interface ContributionProfile { + cycleTimeHours: number; + reviewResponseHours: number; + throughputScore: number; + qualityScore: number; + focusScore: number; + overallScore: number; + bottlenecks: string[]; + recommendations: string[]; +} + +const HOURS = 1000 * 60 * 60; + +function hoursBetween(start?: ContributionEvent, end?: ContributionEvent): number { + if (!start || !end) return 0; + const diff = new Date(end.timestamp).getTime() - new Date(start.timestamp).getTime(); + return Math.max(0, Math.round((diff / HOURS) * 10) / 10); +} + +function clampScore(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function buildContributionPerformanceProfile( + events: ContributionEvent[], + testPassRate = 1 +): ContributionProfile { + const sorted = [...events].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + const first = sorted[0]; + const merged = sorted.find((event) => event.type === 'pr_merged'); + const reviewRequested = sorted.find((event) => event.type === 'review_requested'); + const reviewReceived = sorted.find((event) => event.type === 'review_received'); + const changesRequested = sorted.filter((event) => event.type === 'changes_requested').length; + const commits = sorted.filter((event) => event.type === 'commit_pushed').length; + + const cycleTimeHours = hoursBetween(first, merged ?? sorted[sorted.length - 1]); + const reviewResponseHours = hoursBetween(reviewRequested, reviewReceived); + const throughputScore = clampScore(100 - cycleTimeHours * 2); + const qualityScore = clampScore(testPassRate * 100 - changesRequested * 15); + const focusScore = clampScore(100 - Math.max(0, commits - 5) * 8); + const overallScore = clampScore((throughputScore + qualityScore + focusScore) / 3); + + const bottlenecks: string[] = []; + if (cycleTimeHours > 36) bottlenecks.push('Long issue-to-merge cycle time'); + if (reviewResponseHours > 12) bottlenecks.push('Slow review response'); + if (changesRequested > 1) bottlenecks.push('Repeated change requests'); + if (commits > 8) bottlenecks.push('Large PR scope'); + + const recommendations = [ + cycleTimeHours > 36 ? 'Break future issues into smaller PRs.' : 'Keep PR size focused.', + reviewResponseHours > 12 ? 'Respond to reviews within one working session.' : 'Review response time is healthy.', + qualityScore < 80 ? 'Run tests before requesting review.' : 'Quality signals look strong.', + ]; + + return { + cycleTimeHours, + reviewResponseHours, + throughputScore, + qualityScore, + focusScore, + overallScore, + bottlenecks, + recommendations, + }; +} + +export const SAMPLE_CONTRIBUTION_EVENTS: ContributionEvent[] = [ + { id: '1', type: 'issue_assigned', timestamp: '2026-06-20T09:00:00.000Z' }, + { id: '2', type: 'branch_created', timestamp: '2026-06-20T09:20:00.000Z' }, + { id: '3', type: 'commit_pushed', timestamp: '2026-06-20T11:00:00.000Z' }, + { id: '4', type: 'pr_opened', timestamp: '2026-06-20T13:00:00.000Z' }, + { id: '5', type: 'review_requested', timestamp: '2026-06-20T13:05:00.000Z' }, + { id: '6', type: 'review_received', timestamp: '2026-06-20T17:00:00.000Z' }, + { id: '7', type: 'pr_merged', timestamp: '2026-06-21T10:00:00.000Z' }, +]; diff --git a/frontend/src/lib/mentor-booking.ts b/frontend/src/lib/mentor-booking.ts new file mode 100644 index 00000000..35b8f10e --- /dev/null +++ b/frontend/src/lib/mentor-booking.ts @@ -0,0 +1,68 @@ +export interface MentorSlot { + id: string; + mentorId: string; + startsAt: string; + endsAt: string; + capacity: number; + booked: number; + tags: string[]; +} + +export interface BookingRequest { + learnerId: string; + preferredTags: string[]; + earliestAt: string; +} + +export function findBestMentorSlot( + slots: MentorSlot[], + request: BookingRequest +): MentorSlot | null { + const earliest = new Date(request.earliestAt).getTime(); + const preferred = new Set(request.preferredTags.map((tag) => tag.toLowerCase())); + + return [...slots] + .filter((slot) => slot.booked < slot.capacity) + .filter((slot) => new Date(slot.startsAt).getTime() >= earliest) + .sort((a, b) => { + const aMatches = a.tags.filter((tag) => preferred.has(tag.toLowerCase())).length; + const bMatches = b.tags.filter((tag) => preferred.has(tag.toLowerCase())).length; + if (aMatches !== bMatches) return bMatches - aMatches; + const aFill = a.booked / a.capacity; + const bFill = b.booked / b.capacity; + if (aFill !== bFill) return aFill - bFill; + return new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(); + })[0] ?? null; +} + +export function reserveMentorSlot(slots: MentorSlot[], slotId: string): MentorSlot[] { + return slots.map((slot) => { + if (slot.id !== slotId) return slot; + if (slot.booked >= slot.capacity) { + throw new Error('Mentor slot is full'); + } + return { ...slot, booked: slot.booked + 1 }; + }); +} + +export function detectMentorConflicts(slots: MentorSlot[]): string[] { + const conflicts: string[] = []; + const byMentor = new Map(); + for (const slot of slots) { + byMentor.set(slot.mentorId, [...(byMentor.get(slot.mentorId) ?? []), slot]); + } + + for (const [mentorId, mentorSlots] of byMentor.entries()) { + const sorted = mentorSlots.sort( + (a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime() + ); + for (let i = 1; i < sorted.length; i += 1) { + const previous = sorted[i - 1]!; + const current = sorted[i]!; + if (new Date(previous.endsAt).getTime() > new Date(current.startsAt).getTime()) { + conflicts.push(`${mentorId}:${previous.id}:${current.id}`); + } + } + } + return conflicts; +} diff --git a/frontend/src/lib/merkle-tree-builder.ts b/frontend/src/lib/merkle-tree-builder.ts new file mode 100644 index 00000000..ba536005 --- /dev/null +++ b/frontend/src/lib/merkle-tree-builder.ts @@ -0,0 +1,127 @@ +export interface MerkleNode { + id: string; + hash: string; + left?: MerkleNode; + right?: MerkleNode; + isLeaf: boolean; + value?: string; + level: number; + index: number; +} + +export interface MerkleProofStep { + hash: string; + position: 'left' | 'right'; +} + +export interface MerkleBuildResult { + root: MerkleNode; + levels: MerkleNode[][]; + leafCount: number; + depth: number; + duplicateLeafCount: number; +} + +export function stableHash(input: string): string { + let hash = 2166136261; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +export function normalizeMerkleLeaves(values: string[]): string[] { + const seen = new Set(); + return values + .map((value) => value.trim()) + .filter(Boolean) + .filter((value) => { + const key = value.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function buildOptimizedMerkleTree(values: string[]): MerkleBuildResult { + const leaves = normalizeMerkleLeaves(values); + if (leaves.length === 0) { + const root: MerkleNode = { + id: 'root-empty', + hash: '00000000', + isLeaf: false, + level: 0, + index: 0, + }; + return { root, levels: [[root]], leafCount: 0, depth: 0, duplicateLeafCount: 0 }; + } + + let current = leaves.map((value, index) => ({ + id: `leaf-${index}`, + hash: stableHash(value), + isLeaf: true, + value, + level: 0, + index, + })); + const levels: MerkleNode[][] = [current]; + let duplicateLeafCount = 0; + let level = 0; + + while (current.length > 1) { + const next: MerkleNode[] = []; + for (let i = 0; i < current.length; i += 2) { + const left = current[i]!; + const right = current[i + 1] ?? left; + if (!current[i + 1]) duplicateLeafCount += 1; + next.push({ + id: `node-${level + 1}-${Math.floor(i / 2)}`, + hash: stableHash(`${left.hash}:${right.hash}`), + left, + right, + isLeaf: false, + level: level + 1, + index: Math.floor(i / 2), + }); + } + current = next; + levels.push(current); + level += 1; + } + + return { + root: current[0]!, + levels, + leafCount: leaves.length, + depth: levels.length - 1, + duplicateLeafCount, + }; +} + +export function getMerkleProof(result: MerkleBuildResult, value: string): MerkleProofStep[] { + const target = value.trim().toLowerCase(); + let index = result.levels[0].findIndex((leaf) => leaf.value?.toLowerCase() === target); + if (index < 0) return []; + + const proof: MerkleProofStep[] = []; + for (let level = 0; level < result.levels.length - 1; level += 1) { + const layer = result.levels[level]!; + const isRight = index % 2 === 1; + const siblingIndex = isRight ? index - 1 : index + 1; + const sibling = layer[siblingIndex] ?? layer[index]!; + proof.push({ hash: sibling.hash, position: isRight ? 'left' : 'right' }); + index = Math.floor(index / 2); + } + return proof; +} + +export function verifyMerkleProof(value: string, proof: MerkleProofStep[], rootHash: string): boolean { + let hash = stableHash(value.trim()); + for (const step of proof) { + hash = step.position === 'left' + ? stableHash(`${step.hash}:${hash}`) + : stableHash(`${hash}:${step.hash}`); + } + return hash === rootHash; +}