From ed8445c8f25066ff9f6252a31f0065beb0dec214 Mon Sep 17 00:00:00 2001 From: Evandro Ribeiro Date: Sat, 31 Oct 2026 21:12:48 +0000 Subject: [PATCH] feat: add tooling adoption detectors, /api/tooling endpoint and /tooling page (fixes #13) --- apps/api/src/hackproof/db.py | 4 +- apps/api/src/hackproof/github/dependencies.py | 81 +++++++++ apps/api/src/hackproof/github/models.py | 3 +- apps/api/src/hackproof/github/scan.py | 18 +- apps/api/src/hackproof/hackathon.py | 12 ++ apps/api/src/hackproof/queries.py | 161 +++++++++++++++++- apps/api/src/hackproof/routes.py | 7 + apps/api/src/hackproof/schemas.py | 17 ++ apps/api/tests/test_dependencies.py | 111 +++++++++++- apps/api/tests/test_tooling_api.py | 85 +++++++++ apps/web/src/app.tsx | 2 + .../components/charts/distribution-bar.tsx | 52 ++++++ .../web/src/components/layout/site-header.tsx | 10 +- apps/web/src/hooks/use-tooling.ts | 8 + apps/web/src/lib/types.ts | 17 ++ apps/web/src/pages/tooling.tsx | 86 ++++++++++ 16 files changed, 666 insertions(+), 8 deletions(-) create mode 100644 apps/api/tests/test_tooling_api.py create mode 100644 apps/web/src/components/charts/distribution-bar.tsx create mode 100644 apps/web/src/hooks/use-tooling.ts create mode 100644 apps/web/src/pages/tooling.tsx diff --git a/apps/api/src/hackproof/db.py b/apps/api/src/hackproof/db.py index f72d7b5..5370e23 100644 --- a/apps/api/src/hackproof/db.py +++ b/apps/api/src/hackproof/db.py @@ -88,9 +88,11 @@ total_commits INTEGER, stellar_added_in_window BOOLEAN, scanned_at TIMESTAMPTZ, - declared_track TEXT + declared_track TEXT, + tooling JSONB NOT NULL DEFAULT '{}' ); ALTER TABLE repos ADD COLUMN IF NOT EXISTS declared_track TEXT; +ALTER TABLE repos ADD COLUMN IF NOT EXISTS tooling JSONB NOT NULL DEFAULT '{}'; CREATE TABLE IF NOT EXISTS repo_contributors ( full_name TEXT NOT NULL, diff --git a/apps/api/src/hackproof/github/dependencies.py b/apps/api/src/hackproof/github/dependencies.py index 91ded2b..288b1cf 100644 --- a/apps/api/src/hackproof/github/dependencies.py +++ b/apps/api/src/hackproof/github/dependencies.py @@ -2,6 +2,7 @@ import re import tomllib from pathlib import PurePosixPath +from typing import Any from hackproof.github.models import DependencyFingerprint @@ -16,6 +17,29 @@ REQUIREMENT = re.compile(r"^([A-Za-z0-9_.-]+)\s*(?:[=~<>!]=?\s*([\w.*-]+))?") GO_REQUIRE = re.compile(r"^\s*(?:require\s+)?(\S+)\s+(v[\w.+-]+)", re.MULTILINE) +OPENZEPPELIN_PACKAGES = { + "stellar-contracts", + "openzeppelin-stellar-contracts", + "openzeppelin_stellar_contracts", + "@openzeppelin/contracts", + "@openzeppelin/stellar-contracts", +} + +WALLETS_KIT_PACKAGES = { + "@creit.tech/stellar-wallets-kit", + "stellar-wallets-kit", +} + +FREIGHTER_PACKAGES = { + "@stellar/freighter-api", + "freighter-api", +} + +PASSKEY_PACKAGES = { + "passkey-kit", + "@stellar/passkey-kit", +} + def _cargo(text: str) -> dict[str, str | None]: data = tomllib.loads(text) @@ -82,3 +106,60 @@ def fingerprint(files: dict[str, str], wanted: dict[str, list[str]]) -> Dependen if ecosystem not in result.ecosystems: result.ecosystems.append(ecosystem) return result + + +def detect_openzeppelin(files: dict[str, str]) -> bool: + for path, text in files.items(): + name = PurePosixPath(path).name + if name not in PARSERS: + continue + try: + declared = PARSERS[name](text) + if any(pkg in declared for pkg in OPENZEPPELIN_PACKAGES): + return True + except Exception: + continue + return False + + +def detect_framework(files: dict[str, str]) -> str: + for path, text in files.items(): + if PurePosixPath(path).name == "package.json": + try: + data = json.loads(text) + deps: dict[str, Any] = {} + for section in ("dependencies", "devDependencies"): + deps.update(data.get(section, {})) + if "next" in deps: + return "Next.js" + if "vite" in deps: + return "Vite" + except Exception: + continue + return "Other" + + +def detect_ai_tooling(paths: list[str]) -> list[str]: + found = set() + for path in paths: + normalized = "/" + path.strip("/").lower() + parts = PurePosixPath(path).parts + name = parts[-1].lower() if parts else "" + if name == "claude.md": + found.add("CLAUDE.md") + elif name == "agents.md": + found.add("AGENTS.md") + elif name == ".cursorrules" or ".cursor/rules" in normalized or "cursorrules" in name: + found.add("Cursor rules") + elif "copilot-instructions" in normalized: + found.add("Copilot instructions") + return sorted(found) + + +def parse_major_version(spec: str | None) -> str: + if not spec: + return "Unknown" + m = re.search(r"(\d+)", spec) + if m: + return f"v{m.group(1)}" + return "Unknown" diff --git a/apps/api/src/hackproof/github/models.py b/apps/api/src/hackproof/github/models.py index f5ec733..93b64a5 100644 --- a/apps/api/src/hackproof/github/models.py +++ b/apps/api/src/hackproof/github/models.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -60,3 +60,4 @@ class RepoScan(BaseModel): stellar_added_in_window: bool | None integrations: list[Detection] contributors: list[Contributor] + tooling: dict[str, Any] = Field(default_factory=dict) diff --git a/apps/api/src/hackproof/github/scan.py b/apps/api/src/hackproof/github/scan.py index 375ae74..70259a2 100644 --- a/apps/api/src/hackproof/github/scan.py +++ b/apps/api/src/hackproof/github/scan.py @@ -8,7 +8,13 @@ from hackproof.github.client import GithubClient from hackproof.github.demo import scan_demo -from hackproof.github.dependencies import MANIFESTS, fingerprint +from hackproof.github.dependencies import ( + MANIFESTS, + detect_ai_tooling, + detect_framework, + detect_openzeppelin, + fingerprint, +) from hackproof.github.files import fetch_texts from hackproof.github.history import stellar_added_in_window from hackproof.github.models import Candidate, Contributor, Ref, RepoScan @@ -123,6 +129,12 @@ async def scan(self, repo: Candidate) -> RepoScan | None: if dependencies.packages else None ) + all_paths = [entry.get("path", "") for entry in tree.get("tree", [])] + tooling = { + "framework": detect_framework(files), + "ai": detect_ai_tooling(all_paths), + "openzeppelin": detect_openzeppelin(files), + } return RepoScan( full_name=repo.full_name, declared_track=declared_track(files), @@ -131,6 +143,7 @@ async def scan(self, repo: Candidate) -> RepoScan | None: stellar_added_in_window=added, integrations=detect(files, self._catalog, repo.owner), contributors=contributors_from(people), + tooling=tooling, ) @@ -178,11 +191,12 @@ async def store_scan(connection: asyncpg.Connection, scan: RepoScan) -> None: await connection.execute( """ UPDATE repos SET dependencies = $2, stellar_added_in_window = $3, - declared_track = $4, scanned_at = now() + declared_track = $4, tooling = $5, scanned_at = now() WHERE full_name = $1 """, scan.full_name, scan.dependencies.model_dump(), scan.stellar_added_in_window, scan.declared_track, + scan.tooling, ) diff --git a/apps/api/src/hackproof/hackathon.py b/apps/api/src/hackproof/hackathon.py index a6ef8e3..74c5ccc 100644 --- a/apps/api/src/hackproof/hackathon.py +++ b/apps/api/src/hackproof/hackathon.py @@ -36,6 +36,7 @@ class Hackathon(BaseModel): window: Window awards: list[Award] = [] discovery: Discovery + hidden: list[str] = [] @lru_cache @@ -46,3 +47,14 @@ def get_hackathon() -> Hackathon: def places() -> dict[str, Place]: return {award.project_id: award.place for award in get_hackathon().awards} + + +def hidden_ids() -> list[str]: + raw = get_hackathon().hidden + ids = set(raw) + for item in raw: + if item.startswith("repo:"): + ids.add(item[5:]) + elif not item.startswith("acct:"): + ids.add(f"repo:{item}") + return sorted(ids) diff --git a/apps/api/src/hackproof/queries.py b/apps/api/src/hackproof/queries.py index 9f9e1ab..c38ec51 100644 --- a/apps/api/src/hackproof/queries.py +++ b/apps/api/src/hackproof/queries.py @@ -1,8 +1,9 @@ +from collections import Counter from datetime import datetime from typing import Any, Literal from hackproof.db import pool -from hackproof.hackathon import get_hackathon, places +from hackproof.hackathon import get_hackathon, hidden_ids, places from hackproof.integrations.catalog import get_catalog from hackproof.linking.methods import METHOD_RANK_SQL @@ -256,3 +257,161 @@ async def fetch_integrations() -> list[dict[str, Any]]: async def fetch_countries(limit: int = 12) -> list[dict[str, Any]]: async with pool().acquire() as connection: return [dict(row) for row in await connection.fetch(COUNTRIES_SQL, limit)] + +TOOLING_SQL = """ +SELECT p.project_id, p.full_name, r.dependencies, COALESCE(r.tooling, '{}'::jsonb) AS tooling, + ARRAY(SELECT i.partner FROM repo_integrations i + WHERE i.full_name = p.full_name) AS integrations +FROM projects p +JOIN repos r ON r.full_name = p.full_name +WHERE p.tier IN ('verified', 'likely') + AND ($1::text[] IS NULL OR NOT (p.project_id = ANY($1) OR p.full_name = ANY($1))) +""" + + +async def fetch_tooling() -> dict[str, Any]: + from hackproof.github.dependencies import parse_major_version + + hidden = hidden_ids() or None + async with pool().acquire() as connection: + rows = await connection.fetch(TOOLING_SQL, hidden) + + total = len(rows) + + oz_count = 0 + wallets_count = 0 + freighter_count = 0 + passkey_count = 0 + + soroban_versions: Counter[str] = Counter() + stellar_versions: Counter[str] = Counter() + framework_counts: Counter[str] = Counter() + ai_counts: Counter[str] = Counter() + + for row in rows: + deps = row["dependencies"] or {} + if isinstance(deps, str): + import json + deps = json.loads(deps) + packages = deps.get("packages", {}) or {} + tooling = row["tooling"] or {} + if isinstance(tooling, str): + import json + tooling = json.loads(tooling) + integrations = set(row["integrations"] or []) + + # 1. OpenZeppelin Stellar Contracts + has_oz = ( + tooling.get("openzeppelin") + or any( + p in packages + for p in ( + "stellar-contracts", + "openzeppelin-stellar-contracts", + "openzeppelin_stellar_contracts", + "@openzeppelin/contracts", + "@openzeppelin/stellar-contracts", + ) + ) + or "openzeppelin" in integrations + ) + if has_oz: + oz_count += 1 + + # 2. Stellar Wallets Kit + has_wallets = ( + "@creit.tech/stellar-wallets-kit" in packages + or "stellar-wallets-kit" in packages + or "stellar-wallets-kit" in integrations + ) + if has_wallets: + wallets_count += 1 + + # 3. Freighter API + has_freighter = ( + "@stellar/freighter-api" in packages + or "freighter" in integrations + or "freighter-api" in packages + ) + if has_freighter: + freighter_count += 1 + + # 4. Passkey Kit + has_passkey = ( + "passkey-kit" in packages + or "@stellar/passkey-kit" in packages + or "passkey" in integrations + ) + if has_passkey: + passkey_count += 1 + + # 5. soroban-sdk major versions + if "soroban-sdk" in packages: + ver = parse_major_version(packages["soroban-sdk"]) + soroban_versions[ver] += 1 + + # 6. stellar-sdk JS versions + sdk_spec = packages.get("@stellar/stellar-sdk") or packages.get("stellar-sdk") + if sdk_spec: + ver = parse_major_version(sdk_spec) + stellar_versions[ver] += 1 + + # 7. Frameworks + fw = tooling.get("framework") + if not fw or fw == "Other": + if "next" in packages: + fw = "Next.js" + elif "vite" in packages: + fw = "Vite" + else: + fw = "Other" + framework_counts[fw] += 1 + + # 8. AI tooling + for tool in tooling.get("ai", []): + ai_counts[tool] += 1 + + def make_items(counter: dict[str, int]) -> list[dict[str, Any]]: + items = [] + for name, count in counter.items(): + pct = round((count / total * 100), 1) if total > 0 else 0.0 + items.append({"name": name, "count": count, "percentage": pct}) + return sorted(items, key=lambda x: x["count"], reverse=True) + + kits_items = make_items({ + "Stellar Wallets Kit": wallets_count, + "Freighter API": freighter_count, + "OpenZeppelin Contracts": oz_count, + "Passkey Kit": passkey_count, + }) + + return { + "total_projects": total, + "categories": [ + { + "category": "Ecosystem Kits & Wallets", + "description": "Adoption of official and partner Stellar libraries and kits", + "items": kits_items, + }, + { + "category": "Soroban SDK Major Versions", + "description": "Declared soroban-sdk version in Cargo.toml contracts", + "items": make_items(dict(soroban_versions)), + }, + { + "category": "Stellar SDK (JS) Versions", + "description": "Declared @stellar/stellar-sdk version in web applications", + "items": make_items(dict(stellar_versions)), + }, + { + "category": "Frontend Frameworks", + "description": "Application architecture: Next.js vs Vite vs other", + "items": make_items(dict(framework_counts)), + }, + { + "category": "AI Tooling & Rules", + "description": "Agent instructions, prompts and rules present in repository trees", + "items": make_items(dict(ai_counts)), + }, + ], + } diff --git a/apps/api/src/hackproof/routes.py b/apps/api/src/hackproof/routes.py index 1c17422..ff06d93 100644 --- a/apps/api/src/hackproof/routes.py +++ b/apps/api/src/hackproof/routes.py @@ -15,6 +15,7 @@ Stats, Tier, TimelinePoint, + ToolingStats, WasmUpload, ) @@ -47,6 +48,12 @@ async def countries() -> list[CountryCount]: return [CountryCount(**row) for row in await queries.fetch_countries()] + + +@router.get("/tooling") +async def tooling() -> ToolingStats: + return ToolingStats(**await queries.fetch_tooling()) + @router.get("/integrations") async def integrations() -> list[Integration]: return [Integration(**row) for row in await queries.fetch_integrations()] diff --git a/apps/api/src/hackproof/schemas.py b/apps/api/src/hackproof/schemas.py index f9918d4..aff6bb7 100644 --- a/apps/api/src/hackproof/schemas.py +++ b/apps/api/src/hackproof/schemas.py @@ -149,3 +149,20 @@ class CountryCount(BaseModel): class Page[T](BaseModel): items: list[T] total: int + + +class ToolingItem(BaseModel): + name: str + count: int + percentage: float + + +class ToolingGroup(BaseModel): + category: str + description: str + items: list[ToolingItem] + + +class ToolingStats(BaseModel): + total_projects: int + categories: list[ToolingGroup] diff --git a/apps/api/tests/test_dependencies.py b/apps/api/tests/test_dependencies.py index 96e12ec..cdcee35 100644 --- a/apps/api/tests/test_dependencies.py +++ b/apps/api/tests/test_dependencies.py @@ -1,4 +1,13 @@ -from hackproof.github.dependencies import fingerprint +from hackproof.github.dependencies import ( + FREIGHTER_PACKAGES, + PASSKEY_PACKAGES, + WALLETS_KIT_PACKAGES, + detect_ai_tooling, + detect_framework, + detect_openzeppelin, + fingerprint, + parse_major_version, +) WANTED = { "rust": ["soroban-sdk"], @@ -54,3 +63,103 @@ def test_repos_without_stellar_dependencies_have_no_fingerprint(): assert ( fingerprint({"package.json": '{"dependencies": {"react": "19"}}'}, WANTED).ecosystems == [] ) + + +def test_detects_openzeppelin_in_cargo_and_package_json(): + # 1. Cargo.toml table fixture + cargo_fixture = """ + [dependencies] + soroban-sdk = "22.0.0" + openzeppelin-stellar-contracts = { version = "0.2.0" } + """ + assert detect_openzeppelin({"Cargo.toml": cargo_fixture}) is True + + # 2. Cargo.toml inline string fixture + cargo_str = """ + [dependencies] + stellar-contracts = "0.1.0" + """ + assert detect_openzeppelin({"Cargo.toml": cargo_str}) is True + + # 3. package.json fixture + pkg_fixture = """ + { + "dependencies": { + "@openzeppelin/contracts": "^5.0.0" + } + } + """ + assert detect_openzeppelin({"package.json": pkg_fixture}) is True + + # 4. Manifest without OpenZeppelin + normal_cargo = '[dependencies]\nsoroban-sdk = "22.0.0"' + assert detect_openzeppelin({"Cargo.toml": normal_cargo}) is False + + +def test_detects_wallets_kit_and_freighter_and_passkey(): + pkg_fixture = { + "package.json": """ + { + "dependencies": { + "@creit.tech/stellar-wallets-kit": "^1.1.0", + "@stellar/freighter-api": "^3.0.0", + "passkey-kit": "^0.4.0" + } + } + """ + } + result = fingerprint( + pkg_fixture, + { + "js": [ + *WALLETS_KIT_PACKAGES, + *FREIGHTER_PACKAGES, + *PASSKEY_PACKAGES, + ] + }, + ) + assert "@creit.tech/stellar-wallets-kit" in result.packages + assert "@stellar/freighter-api" in result.packages + assert "passkey-kit" in result.packages + + +def test_detects_next_and_vite_frameworks(): + next_fixture = '{"dependencies": {"next": "15.0.0", "react": "19"}}' + assert detect_framework({"package.json": next_fixture}) == "Next.js" + + vite_fixture = '{"devDependencies": {"vite": "^6.0.0", "react": "19"}}' + assert detect_framework({"frontend/package.json": vite_fixture}) == "Vite" + + other_fixture = '{"dependencies": {"express": "^4.18.0"}}' + assert detect_framework({"package.json": other_fixture}) == "Other" + + no_pkg = {"Cargo.toml": '[dependencies]\nsoroban-sdk = "22"'} + assert detect_framework(no_pkg) == "Other" + + +def test_detects_ai_tooling_in_paths(): + paths = [ + "src/index.ts", + "CLAUDE.md", + ".agents/AGENTS.md", + ".cursorrules", + ".github/copilot-instructions.md", + ] + detected = detect_ai_tooling(paths) + assert "CLAUDE.md" in detected + assert "AGENTS.md" in detected + assert "Cursor rules" in detected + assert "Copilot instructions" in detected + + empty_paths = ["src/main.rs", "Cargo.toml", "README.md"] + assert detect_ai_tooling(empty_paths) == [] + + +def test_parse_major_version(): + assert parse_major_version("^22.0.0") == "v22" + assert parse_major_version("~21.3.1") == "v21" + assert parse_major_version("20.1") == "v20" + assert parse_major_version("v14.0.0") == "v14" + assert parse_major_version(">=13.0.0") == "v13" + assert parse_major_version(None) == "Unknown" + assert parse_major_version("git-ref") == "Unknown" diff --git a/apps/api/tests/test_tooling_api.py b/apps/api/tests/test_tooling_api.py new file mode 100644 index 0000000..fea3f41 --- /dev/null +++ b/apps/api/tests/test_tooling_api.py @@ -0,0 +1,85 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from hackproof.main import app + + +@pytest.fixture(autouse=True) +def mock_pool_lifespan(monkeypatch): + monkeypatch.setattr("hackproof.main.open_pool", AsyncMock()) + monkeypatch.setattr("hackproof.main.close_pool", AsyncMock()) + + +def test_tooling_api_returns_categories(monkeypatch): + mock_data = { + "total_projects": 12, + "categories": [ + { + "category": "Ecosystem Kits & Wallets", + "description": "Adoption of official and partner Stellar libraries and kits", + "items": [ + {"name": "Stellar Wallets Kit", "count": 6, "percentage": 50.0}, + {"name": "Freighter API", "count": 4, "percentage": 33.3}, + {"name": "OpenZeppelin Contracts", "count": 3, "percentage": 25.0}, + {"name": "Passkey Kit", "count": 2, "percentage": 16.7}, + ], + }, + { + "category": "Soroban SDK Major Versions", + "description": "Declared soroban-sdk version in Cargo.toml contracts", + "items": [ + {"name": "v22", "count": 8, "percentage": 66.7}, + {"name": "v21", "count": 3, "percentage": 25.0}, + ], + }, + { + "category": "Stellar SDK (JS) Versions", + "description": "Declared @stellar/stellar-sdk version in web applications", + "items": [ + {"name": "v14", "count": 7, "percentage": 58.3}, + {"name": "v13", "count": 2, "percentage": 16.7}, + ], + }, + { + "category": "Frontend Frameworks", + "description": "Application architecture: Next.js vs Vite vs other", + "items": [ + {"name": "Next.js", "count": 6, "percentage": 50.0}, + {"name": "Vite", "count": 4, "percentage": 33.3}, + {"name": "Other", "count": 2, "percentage": 16.7}, + ], + }, + { + "category": "AI Tooling & Rules", + "description": "Agent instructions, prompts and rules present in repository trees", + "items": [ + {"name": "CLAUDE.md", "count": 5, "percentage": 41.7}, + {"name": "Cursor rules", "count": 4, "percentage": 33.3}, + {"name": "AGENTS.md", "count": 3, "percentage": 25.0}, + {"name": "Copilot instructions", "count": 1, "percentage": 8.3}, + ], + }, + ], + } + + monkeypatch.setattr("hackproof.queries.fetch_tooling", AsyncMock(return_value=mock_data)) + + with TestClient(app) as client: + resp = client.get("/api/tooling") + assert resp.status_code == 200 + body = resp.json() + assert body["total_projects"] == 12 + assert len(body["categories"]) == 5 + + categories = {cat["category"]: cat for cat in body["categories"]} + assert "Ecosystem Kits & Wallets" in categories + assert "Soroban SDK Major Versions" in categories + assert "Stellar SDK (JS) Versions" in categories + assert "Frontend Frameworks" in categories + assert "AI Tooling & Rules" in categories + + kits = categories["Ecosystem Kits & Wallets"]["items"] + assert any(item["name"] == "OpenZeppelin Contracts" for item in kits) + assert any(item["name"] == "Stellar Wallets Kit" for item in kits) diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 1ab34f3..56d8465 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -10,6 +10,7 @@ import { SiteHeader } from '@/components/layout/site-header' import { BuilderPage } from '@/pages/builder' import { HomePage } from '@/pages/home' import { ProjectPage } from '@/pages/project' +import { ToolingPage } from '@/pages/tooling' function Shell() { const { view } = useChatPanel() @@ -26,6 +27,7 @@ function Shell() { } /> } /> } /> + } /> diff --git a/apps/web/src/components/charts/distribution-bar.tsx b/apps/web/src/components/charts/distribution-bar.tsx new file mode 100644 index 0000000..1a1ff55 --- /dev/null +++ b/apps/web/src/components/charts/distribution-bar.tsx @@ -0,0 +1,52 @@ +import type { ToolingItem } from '@/lib/types' + +interface DistributionBarProps { + items: ToolingItem[] + maxCount?: number + className?: string +} + +const BAR_COLORS = [ + 'bg-emerald-500', + 'bg-blue-500', + 'bg-violet-500', + 'bg-amber-500', + 'bg-rose-500', + 'bg-cyan-500', + 'bg-fuchsia-500', + 'bg-indigo-500', +] + +export function DistributionBar({ items, maxCount, className = '' }: DistributionBarProps) { + const highest = maxCount ?? Math.max(...items.map((i) => i.count), 1) + + return ( +
+ {items.map((item, index) => { + const color = BAR_COLORS[index % BAR_COLORS.length] + const widthPct = Math.min(100, Math.max(2, (item.count / highest) * 100)) + + return ( +
+
+ {item.name} +
+ {item.count} + + ({item.percentage}%) + +
+
+
+
+
+
+ ) + })} +
+ ) +} diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index bc54eb7..36b0918 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -1,4 +1,4 @@ -import { CrownIcon, ExternalLinkIcon, MoonIcon, SunIcon } from 'lucide-react' +import { CrownIcon, ExternalLinkIcon, MoonIcon, SunIcon, WrenchIcon } from 'lucide-react' import { useTheme } from 'next-themes' import { Link } from 'react-router' @@ -14,7 +14,13 @@ export function SiteHeader() { HackProof -
+
+ + Tooling + apiGet('/tooling') }) +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 372ebef..17f49ff 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -154,3 +154,20 @@ export interface CountryCount { country: string contributors: number } + +export interface ToolingItem { + name: string + count: number + percentage: number +} + +export interface ToolingGroup { + category: string + description: string + items: ToolingItem[] +} + +export interface ToolingStats { + total_projects: number + categories: ToolingGroup[] +} diff --git a/apps/web/src/pages/tooling.tsx b/apps/web/src/pages/tooling.tsx new file mode 100644 index 0000000..8397e78 --- /dev/null +++ b/apps/web/src/pages/tooling.tsx @@ -0,0 +1,86 @@ +import { LayersIcon, WrenchIcon } from 'lucide-react' + +import { DistributionBar } from '@/components/charts/distribution-bar' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { useTooling } from '@/hooks/use-tooling' + +export function ToolingPage() { + const { data, isPending, error } = useTooling() + + if (isPending) { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+
+ ) + } + + if (error || !data) { + return ( +
+ +

Unable to load tooling adoption stats

+

+ {error instanceof Error ? error.message : 'Please check your connection or try again.'} +

+
+ ) + } + + return ( +
+
+
+
+ +
+
+

Tooling Adoption

+

+ What does the winning stack look like? Adoption rates across{' '} + {data.total_projects} verified + and likely hackathon projects. +

+
+
+
+ +
+ {data.categories.map((group) => ( + + + {group.category} + {group.description} + + + {group.items.length > 0 ? ( + + ) : ( +

+ No packages detected yet in this category. +

+ )} +
+
+ ))} +
+
+ ) +}