From 45bda8009fa177e55c2911c78ed047d5c84efe3a Mon Sep 17 00:00:00 2001 From: Shivansh Gupta Date: Wed, 4 Mar 2026 23:29:01 +0530 Subject: [PATCH 1/8] feat: implement SkillContext for optimized data fetching --- web-app/src/context/SkillContext.tsx | 76 ++++++++++++++++++++++++++++ web-app/src/main.tsx | 5 +- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 web-app/src/context/SkillContext.tsx diff --git a/web-app/src/context/SkillContext.tsx b/web-app/src/context/SkillContext.tsx new file mode 100644 index 00000000..3ef74f73 --- /dev/null +++ b/web-app/src/context/SkillContext.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; +import type { Skill, StarMap } from '../types'; +import { supabase } from '../lib/supabase'; + +interface SkillContextType { + skills: Skill[]; + stars: StarMap; + loading: boolean; + refreshSkills: () => Promise; +} + +const SkillContext = createContext(undefined); + +export function SkillProvider({ children }: { children: React.ReactNode }) { + const [skills, setSkills] = useState([]); + const [stars, setStars] = useState({}); + const [loading, setLoading] = useState(true); + + const fetchSkillsAndStars = useCallback(async (silent = false) => { + if (!silent) setLoading(true); + try { + // Fetch skills index + const res = await fetch('/skills.json'); + const data = await res.json(); + setSkills(data); + + // Fetch stars from Supabase if available + if (supabase) { + const { data: starData, error } = await supabase + .from('skill_stars') + .select('skill_id, star_count'); + + if (!error && starData) { + const starMap: StarMap = {}; + starData.forEach((item: { skill_id: string; star_count: number }) => { + starMap[item.skill_id] = item.star_count; + }); + setStars(starMap); + } + } + } catch (err) { + console.error('SkillContext: Failed to load skills', err); + } finally { + if (!silent) setLoading(false); + } + }, []); + + useEffect(() => { + fetchSkillsAndStars(); + }, [fetchSkillsAndStars]); + + const refreshSkills = useCallback(async () => { + await fetchSkillsAndStars(true); + }, [fetchSkillsAndStars]); + + const value = useMemo(() => ({ + skills, + stars, + loading, + refreshSkills + }), [skills, stars, loading, refreshSkills]); + + return ( + + {children} + + ); +} + +export function useSkills() { + const context = useContext(SkillContext); + if (context === undefined) { + throw new Error('useSkills must be used within a SkillProvider'); + } + return context; +} diff --git a/web-app/src/main.tsx b/web-app/src/main.tsx index 6545cc9a..533d286c 100644 --- a/web-app/src/main.tsx +++ b/web-app/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App'; +import { SkillProvider } from './context/SkillContext'; const rootElement = document.getElementById('root'); if (!rootElement) { @@ -10,6 +11,8 @@ if (!rootElement) { createRoot(rootElement).render( - + + + , ); From 804b5f6d75adb3f26cf6cb26933c131176ddec7c Mon Sep 17 00:00:00 2001 From: Shivansh Gupta Date: Wed, 4 Mar 2026 23:33:33 +0530 Subject: [PATCH 2/8] feat: optimize Home page with list virtualization and debounced search --- web-app/src/components/SkillCard.tsx | 66 +++++++++ web-app/src/pages/Home.tsx | 201 +++++++++------------------ 2 files changed, 134 insertions(+), 133 deletions(-) create mode 100644 web-app/src/components/SkillCard.tsx diff --git a/web-app/src/components/SkillCard.tsx b/web-app/src/components/SkillCard.tsx new file mode 100644 index 00000000..fe7ea3cd --- /dev/null +++ b/web-app/src/components/SkillCard.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Book, ArrowRight } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { SkillStarButton } from './SkillStarButton'; +import type { Skill } from '../types'; + +interface SkillCardProps { + skill: Skill; + starCount: number; +} + +export const SkillCard = React.memo(({ skill, starCount }: SkillCardProps) => { + return ( + + +
+
+
+ +
+ + {skill.category || 'Uncategorized'} + +
+ +
+ +

+ @{skill.name} +

+ +

+ {skill.description} +

+ +
+ Risk: {skill.risk || 'unknown'} + {skill.date_added && ( + 📅 {skill.date_added} + )} +
+ +
+ Read Skill +
+ +
+ ); +}); + +SkillCard.displayName = 'SkillCard'; diff --git a/web-app/src/pages/Home.tsx b/web-app/src/pages/Home.tsx index b5263a50..543846c7 100644 --- a/web-app/src/pages/Home.tsx +++ b/web-app/src/pages/Home.tsx @@ -1,61 +1,38 @@ -import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Search, Filter, Book, AlertCircle, ArrowRight, RefreshCw, ArrowUpDown } from 'lucide-react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, Filter, AlertCircle, RefreshCw, ArrowUpDown } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; -import { supabase } from '../lib/supabase'; -import { SkillStarButton } from '../components/SkillStarButton'; -import type { Skill, StarMap, SyncMessage, CategoryStats } from '../types'; +import { VirtuosoGrid } from 'react-virtuoso'; +import debounce from 'lodash.debounce'; +import { useSkills } from '../context/SkillContext'; +import { SkillCard } from '../components/SkillCard'; +import type { Skill, SyncMessage, CategoryStats } from '../types'; export function Home(): React.ReactElement { - const [skills, setSkills] = useState([]); - const [filteredSkills, setFilteredSkills] = useState([]); + const { skills, stars, loading, refreshSkills } = useSkills(); const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); - const [loading, setLoading] = useState(true); - const [stars, setStars] = useState({}); const [sortBy, setSortBy] = useState('default'); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); - useEffect(() => { - const fetchSkillsAndStars = async () => { - try { - // Fetch basic skill data - const res = await fetch('/skills.json'); - const data = await res.json(); - - setSkills(data); - setFilteredSkills(data); - - // Fetch star counts if supabase is configured - if (supabase) { - const { data: starData, error } = await supabase - .from('skill_stars') - .select('skill_id, star_count'); - - if (!error && starData) { - const starMap: StarMap = {}; - starData.forEach((item: { skill_id: string; star_count: number }) => { - starMap[item.skill_id] = item.star_count; - }); - setStars(starMap); - } - } - } catch (err) { - console.error('Failed to load skills', err); - } finally { - setLoading(false); - } - }; - - fetchSkillsAndStars(); - }, []); + // Debounce search input to avoid excessive filtering on every keystroke + const debouncedSetSearch = useCallback( + debounce((value: string) => { + setDebouncedSearch(value); + }, 300), + [] + ); useEffect(() => { + debouncedSetSearch(search); + }, [search, debouncedSetSearch]); + + const filteredSkills = useMemo(() => { let result = [...skills]; - if (search) { - const lowerSearch = search.toLowerCase(); + if (debouncedSearch) { + const lowerSearch = debouncedSearch.toLowerCase(); result = result.filter(skill => skill.name.toLowerCase().includes(lowerSearch) || skill.description.toLowerCase().includes(lowerSearch) @@ -75,20 +52,24 @@ export function Home(): React.ReactElement { result = [...result].sort((a, b) => a.name.localeCompare(b.name)); } - setFilteredSkills(result); - }, [search, categoryFilter, sortBy, skills, stars]); + return result; + }, [debouncedSearch, categoryFilter, sortBy, skills, stars]); // Sort categories by count (most skills first), with 'uncategorized' at the end - const categoryStats: CategoryStats = {}; - skills.forEach(skill => { - categoryStats[skill.category] = (categoryStats[skill.category] || 0) + 1; - }); + const { categories, categoryStats } = useMemo(() => { + const stats: CategoryStats = {}; + skills.forEach(skill => { + stats[skill.category] = (stats[skill.category] || 0) + 1; + }); - const categories = ['all', ...Object.keys(categoryStats) - .filter(cat => cat !== 'uncategorized') - .sort((a, b) => categoryStats[b] - categoryStats[a]), - ...(categoryStats['uncategorized'] ? ['uncategorized'] : []) - ]; + const cats = ['all', ...Object.keys(stats) + .filter(cat => cat !== 'uncategorized') + .sort((a, b) => stats[b] - stats[a]), + ...(stats['uncategorized'] ? ['uncategorized'] : []) + ]; + + return { categories: cats, categoryStats: stats }; + }, [skills]); const handleSync = async () => { setSyncing(true); @@ -101,11 +82,7 @@ export function Home(): React.ReactElement { setSyncMsg({ type: 'info', text: 'ℹ️ Skills are already up to date!' }); } else { setSyncMsg({ type: 'success', text: `✅ Synced ${data.count} skills!` }); - // Reload skills data only when there are actual updates - const freshRes = await fetch('/skills.json'); - const freshData = await freshRes.json(); - setSkills(freshData); - setFilteredSkills(freshData); + await refreshSkills(); } } else { setSyncMsg({ type: 'error', text: `❌ ${data.error}` }); @@ -119,7 +96,7 @@ export function Home(): React.ReactElement { }; return ( -
+

Explore Skills

@@ -127,13 +104,12 @@ export function Home(): React.ReactElement {
{syncMsg && ( - + {syncMsg.text} )} @@ -189,72 +165,31 @@ export function Home(): React.ReactElement {
-
- - {loading ? ( - [...Array(8)].map((_, i) => ( +
+ {loading ? ( +
+ {[...Array(8)].map((_, i) => (
- )) - ) : filteredSkills.length === 0 ? ( -
- -

No skills found

-

Try adjusting your search or filter.

-
- ) : ( - filteredSkills.map((skill) => ( - - -
-
-
- -
- - {skill.category || 'Uncategorized'} - -
- -
- -

- @{skill.name} -

- -

- {skill.description} -

- -
- Risk: {skill.risk || 'unknown'} - {skill.date_added && ( - 📅 {skill.date_added} - )} -
- -
- Read Skill -
- -
- )) - )} - + ))} +
+ ) : filteredSkills.length === 0 ? ( +
+ +

No skills found

+

Try adjusting your search or filter.

+
+ ) : ( + { + const skill = filteredSkills[index]; + return ; + }} + /> + )}
); From a33a9eaa1a098469b808181b884b26b2c3786773 Mon Sep 17 00:00:00 2001 From: Shivansh Gupta Date: Wed, 4 Mar 2026 23:40:53 +0530 Subject: [PATCH 3/8] feat: optimize SkillDetail with lazy loading and shared state --- web-app/src/pages/SkillDetail.tsx | 73 +++++++++++++++---------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/web-app/src/pages/SkillDetail.tsx b/web-app/src/pages/SkillDetail.tsx index ce73c5dd..adc104b6 100644 --- a/web-app/src/pages/SkillDetail.tsx +++ b/web-app/src/pages/SkillDetail.tsx @@ -1,60 +1,55 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, lazy, Suspense } from 'react'; import { useParams, Link } from 'react-router-dom'; -import Markdown from 'react-markdown'; -import { ArrowLeft, Copy, Check, FileCode, AlertTriangle } from 'lucide-react'; +import { ArrowLeft, Copy, Check, FileCode, AlertTriangle, Loader2 } from 'lucide-react'; import { SkillStarButton } from '../components/SkillStarButton'; -import type { Skill } from '../types'; +import { useSkills } from '../context/SkillContext'; + +// Lazy load heavy markdown component +const Markdown = lazy(() => import('react-markdown')); interface RouteParams { id: string; + [key: string]: string | undefined; } export function SkillDetail(): React.ReactElement { - const { id } = useParams() as RouteParams; - const [skill, setSkill] = useState(null); + const { id } = useParams(); + const { skills, stars, loading: contextLoading } = useSkills(); const [content, setContent] = useState(''); - const [loading, setLoading] = useState(true); + const [contentLoading, setContentLoading] = useState(true); const [copied, setCopied] = useState(false); const [copiedFull, setCopiedFull] = useState(false); const [error, setError] = useState(null); const [customContext, setCustomContext] = useState(''); - const [initialStarCount] = useState(0); + + const skill = useMemo(() => skills.find(s => s.id === id), [skills, id]); + const starCount = useMemo(() => (id ? stars[id] || 0 : 0), [stars, id]); useEffect(() => { - // Fetch index and stars in parallel when possible - const loadData = async () => { + if (contextLoading || !skill) return; + + const loadMarkdown = async () => { + setContentLoading(true); try { - // 1. Fetch index to get skill metadata and path - const res = await fetch('/skills.json'); - const skills: Skill[] = await res.json(); - const foundSkill = skills.find(s => s.id === id); + const cleanPath = skill.path.startsWith('skills/') + ? skill.path.replace('skills/', '') + : skill.path; - if (foundSkill) { - setSkill(foundSkill); + const mdRes = await fetch(`/skills/${cleanPath}/SKILL.md`); + if (!mdRes.ok) throw new Error('Skill file not found'); - // 2. Fetch the actual markdown content - const cleanPath = foundSkill.path.startsWith('skills/') - ? foundSkill.path.replace('skills/', '') - : foundSkill.path; - - const mdRes = await fetch(`/skills/${cleanPath}/SKILL.md`); - if (!mdRes.ok) throw new Error('Skill file not found'); - - const text = await mdRes.text(); - setContent(text); - } else { - setError('Skill not found in registry.'); - } + const text = await mdRes.text(); + setContent(text); } catch (err) { - console.error('Failed to load skill data', err); + console.error('Failed to load skill content', err); setError(err instanceof Error ? err.message : 'Could not load skill content.'); } finally { - setLoading(false); + setContentLoading(false); } }; - loadData(); - }, [id]); + loadMarkdown(); + }, [skill, contextLoading]); const copyToClipboard = () => { if (!skill) return; @@ -79,10 +74,10 @@ export function SkillDetail(): React.ReactElement { setTimeout(() => setCopiedFull(false), 2000); }; - if (loading) { + if (contextLoading || (contentLoading && !error)) { return (
-
+
); } @@ -92,7 +87,7 @@ export function SkillDetail(): React.ReactElement {

Error Loading Skill

-

{error}

+

{error || 'Skill not found in registry.'}

Back to Catalog @@ -126,7 +121,7 @@ export function SkillDetail(): React.ReactElement { )}
@@ -175,7 +170,9 @@ export function SkillDetail(): React.ReactElement {
- {content} +
}> + {content} +
From 44860d5f7bd914d728897f83715e86911b8ed319 Mon Sep 17 00:00:00 2001 From: Shivansh Gupta Date: Wed, 4 Mar 2026 23:57:54 +0530 Subject: [PATCH 4/8] feat(perf): implement global state, list virtualization, and debounced search --- web-app/package-lock.json | 34 ++ web-app/package.json | 3 + web-app/src/pages/Home.tsx | 8 +- web-app/src/pages/SkillDetail.tsx | 141 +++++---- web-app/src/pages/__tests__/Home.test.tsx | 299 ++++++------------ .../src/pages/__tests__/SkillDetail.test.tsx | 189 ++++++----- web-app/src/utils/testUtils.tsx | 30 +- web-app/test_output.txt | 70 ++++ 8 files changed, 403 insertions(+), 371 deletions(-) create mode 100644 web-app/test_output.txt diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 99186ab9..c6f2f956 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -9,15 +9,18 @@ "version": "0.0.0", "dependencies": { "@supabase/supabase-js": "^2.98.0", + "@types/lodash.debounce": "^4.0.9", "clsx": "^2.1.1", "framer-motion": "^12.34.2", "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", + "lodash.debounce": "^4.0.8", "lucide-react": "^0.574.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "react-virtuoso": "^4.18.3", "rehype-highlight": "^7.0.2", "tailwind-merge": "^3.5.0" }, @@ -2069,6 +2072,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4316,6 +4334,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5485,6 +5509,16 @@ "react-dom": ">=18" } }, + "node_modules/react-virtuoso": { + "version": "4.18.3", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.3.tgz", + "integrity": "sha512-fLz/peHAx4Eu0DLHurFEEI7Y6n5CqEoxBh04rgJM9yMuOJah2a9zWg/MUOmZLcp7zuWYorXq5+5bf3IRgkNvWg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/web-app/package.json b/web-app/package.json index aaf0982e..2543dadd 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -13,15 +13,18 @@ }, "dependencies": { "@supabase/supabase-js": "^2.98.0", + "@types/lodash.debounce": "^4.0.9", "clsx": "^2.1.1", "framer-motion": "^12.34.2", "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", + "lodash.debounce": "^4.0.8", "lucide-react": "^0.574.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "react-virtuoso": "^4.18.3", "rehype-highlight": "^7.0.2", "tailwind-merge": "^3.5.0" }, diff --git a/web-app/src/pages/Home.tsx b/web-app/src/pages/Home.tsx index 543846c7..b3d91b36 100644 --- a/web-app/src/pages/Home.tsx +++ b/web-app/src/pages/Home.tsx @@ -1,11 +1,10 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { Search, Filter, AlertCircle, RefreshCw, ArrowUpDown } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; import { VirtuosoGrid } from 'react-virtuoso'; import debounce from 'lodash.debounce'; import { useSkills } from '../context/SkillContext'; import { SkillCard } from '../components/SkillCard'; -import type { Skill, SyncMessage, CategoryStats } from '../types'; +import type { SyncMessage, CategoryStats } from '../types'; export function Home(): React.ReactElement { const { skills, stars, loading, refreshSkills } = useSkills(); @@ -130,6 +129,7 @@ export function Home(): React.ReactElement { setSearch(e.target.value)} @@ -138,6 +138,7 @@ export function Home(): React.ReactElement {