diff --git a/apps/api/src/hackproof/db.py b/apps/api/src/hackproof/db.py index f72d7b5..24141aa 100644 --- a/apps/api/src/hackproof/db.py +++ b/apps/api/src/hackproof/db.py @@ -91,6 +91,7 @@ declared_track TEXT ); ALTER TABLE repos ADD COLUMN IF NOT EXISTS declared_track TEXT; +ALTER TABLE repos ADD COLUMN IF NOT EXISTS first_commit_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS repo_contributors ( full_name TEXT NOT NULL, 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..97122bf 100644 --- a/apps/api/src/hackproof/queries.py +++ b/apps/api/src/hackproof/queries.py @@ -2,7 +2,7 @@ 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 +256,78 @@ 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)] + +def compute_deploy_histogram( + deltas_in_minutes: list[int | float], + bucket_size: int = 30, +) -> tuple[list[dict[str, Any]], float | None]: + if not deltas_in_minutes: + return [], None + + sorted_deltas = sorted(max(0.0, float(d)) for d in deltas_in_minutes) + n = len(sorted_deltas) + if n % 2 == 1: + median = float(sorted_deltas[n // 2]) + else: + median = float((sorted_deltas[n // 2 - 1] + sorted_deltas[n // 2]) / 2) + + max_val = max(sorted_deltas) + max_bucket_idx = int(max_val // bucket_size) + + buckets = [] + for i in range(max_bucket_idx + 1): + start = i * bucket_size + end = (i + 1) * bucket_size + buckets.append({ + "bucket_index": i, + "min_minutes": start, + "max_minutes": end, + "label": f"{start}-{end}m", + "count": 0, + }) + + for d in sorted_deltas: + idx = min(int(d // bucket_size), max_bucket_idx) + buckets[idx]["count"] += 1 + + return buckets, round(median, 1) + + +DEPLOY_DELTAS_SQL = """ +WITH first_deploys AS ( + SELECT COALESCE('repo:' || a.full_name, 'acct:' || c.deployer) AS project_id, + min(c.deployed_at) AS first_deployed_at + FROM contracts c + LEFT JOIN attribution a ON a.subject_kind = 'contract' AND a.subject = c.contract_id + WHERE c.deployed_at BETWEEN $1 AND $2 + GROUP BY COALESCE('repo:' || a.full_name, 'acct:' || c.deployer) +) +SELECT p.project_id, + fd.first_deployed_at, + EXTRACT(EPOCH FROM (fd.first_deployed_at - + CASE + WHEN r.first_commit_at IS NOT NULL THEN r.first_commit_at + WHEN r.created_at IS NOT NULL AND r.created_at >= $1 THEN r.created_at + ELSE $1 + END + )) / 60.0 AS delta_minutes +FROM projects p +JOIN first_deploys fd ON fd.project_id = p.project_id +LEFT JOIN repos r ON r.full_name = p.full_name +WHERE p.tier IN ('verified', 'likely') + AND ($3::text[] IS NULL OR NOT (p.project_id = ANY($3) OR p.full_name = ANY($3))) +""" + + +async def fetch_time_to_deploy() -> dict[str, Any]: + start, end = window() + hidden = hidden_ids() or None + async with pool().acquire() as connection: + rows = await connection.fetch(DEPLOY_DELTAS_SQL, start, end, hidden) + deltas = [row["delta_minutes"] for row in rows if row["delta_minutes"] is not None] + buckets, median = compute_deploy_histogram(deltas) + return { + "total_projects": len(deltas), + "median_minutes": median, + "buckets": buckets, + } diff --git a/apps/api/src/hackproof/routes.py b/apps/api/src/hackproof/routes.py index 1c17422..3ee2df3 100644 --- a/apps/api/src/hackproof/routes.py +++ b/apps/api/src/hackproof/routes.py @@ -15,6 +15,7 @@ Stats, Tier, TimelinePoint, + TimeToDeployResponse, WasmUpload, ) @@ -37,6 +38,12 @@ async def _page(resource: queries.Resource, q: str | None, limit: int, offset: i return Page(items=items, total=total) + + +@router.get("/time-to-deploy") +async def time_to_deploy() -> TimeToDeployResponse: + return TimeToDeployResponse(**await queries.fetch_time_to_deploy()) + @router.get("/timeline") async def timeline() -> list[TimelinePoint]: return [TimelinePoint(**point) for point in await queries.fetch_timeline()] diff --git a/apps/api/src/hackproof/schemas.py b/apps/api/src/hackproof/schemas.py index f9918d4..17b8722 100644 --- a/apps/api/src/hackproof/schemas.py +++ b/apps/api/src/hackproof/schemas.py @@ -149,3 +149,17 @@ class CountryCount(BaseModel): class Page[T](BaseModel): items: list[T] total: int + + +class DeployBucket(BaseModel): + bucket_index: int + min_minutes: int + max_minutes: int + label: str + count: int + + +class TimeToDeployResponse(BaseModel): + total_projects: int + median_minutes: float | None + buckets: list[DeployBucket] diff --git a/apps/api/tests/test_time_to_deploy.py b/apps/api/tests/test_time_to_deploy.py new file mode 100644 index 0000000..681e917 --- /dev/null +++ b/apps/api/tests/test_time_to_deploy.py @@ -0,0 +1,97 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from hackproof.main import app +from hackproof.queries import compute_deploy_histogram + + +@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_bucketing_function_with_three_projects_fixture(): + # Acceptance criteria: A fixture with three projects produces the expected buckets and median + deltas = [15.0, 45.0, 75.0] + buckets, median = compute_deploy_histogram(deltas, bucket_size=30) + + assert median == 45.0 + assert len(buckets) == 3 + + assert buckets[0]["label"] == "0-30m" + assert buckets[0]["count"] == 1 + assert buckets[0]["min_minutes"] == 0 + assert buckets[0]["max_minutes"] == 30 + + assert buckets[1]["label"] == "30-60m" + assert buckets[1]["count"] == 1 + assert buckets[1]["min_minutes"] == 30 + assert buckets[1]["max_minutes"] == 60 + + assert buckets[2]["label"] == "60-90m" + assert buckets[2]["count"] == 1 + assert buckets[2]["min_minutes"] == 60 + assert buckets[2]["max_minutes"] == 90 + + +def test_bucketing_function_edge_cases(): + # 1. Empty input + buckets, median = compute_deploy_histogram([]) + assert buckets == [] + assert median is None + + # 2. Clustered in same bucket + buckets, median = compute_deploy_histogram([5.0, 10.0, 20.0], bucket_size=30) + assert median == 10.0 + assert len(buckets) == 1 + assert buckets[0]["count"] == 3 + + # 3. Even count calculates midpoint median + buckets, median = compute_deploy_histogram([10.0, 20.0, 30.0, 40.0], bucket_size=30) + assert median == 25.0 + + # 4. Negative values clamped to 0 + buckets, median = compute_deploy_histogram([-10.0, 25.0], bucket_size=30) + assert median == 12.5 + assert buckets[0]["count"] == 2 + + +def test_time_to_deploy_api_endpoint(monkeypatch): + mock_payload = { + "total_projects": 3, + "median_minutes": 45.0, + "buckets": [ + {"bucket_index": 0, "min_minutes": 0, "max_minutes": 30, "label": "0-30m", "count": 1}, + { + "bucket_index": 1, + "min_minutes": 30, + "max_minutes": 60, + "label": "30-60m", + "count": 1, + }, + { + "bucket_index": 2, + "min_minutes": 60, + "max_minutes": 90, + "label": "60-90m", + "count": 1, + }, + ], + } + + monkeypatch.setattr( + "hackproof.queries.fetch_time_to_deploy", AsyncMock(return_value=mock_payload) + ) + + with TestClient(app) as client: + resp = client.get("/api/time-to-deploy") + assert resp.status_code == 200 + data = resp.json() + assert data["total_projects"] == 3 + assert data["median_minutes"] == 45.0 + assert len(data["buckets"]) == 3 + assert data["buckets"][0]["label"] == "0-30m" + assert data["buckets"][0]["count"] == 1 diff --git a/apps/web/src/components/charts/histogram.tsx b/apps/web/src/components/charts/histogram.tsx new file mode 100644 index 0000000..2edea95 --- /dev/null +++ b/apps/web/src/components/charts/histogram.tsx @@ -0,0 +1,61 @@ +import { Bar, BarChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from 'recharts' + +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart' +import type { DeployBucket } from '@/lib/types' + +const config = { + count: { label: 'Projects', color: 'var(--chart-1)' }, +} satisfies ChartConfig + +interface HistogramProps { + buckets: DeployBucket[] + median?: number | null + className?: string +} + +export function Histogram({ buckets, median, className = 'h-52 w-full' }: HistogramProps) { + const medianBucket = + median !== null && median !== undefined + ? buckets.find((b) => median >= b.min_minutes && median < b.max_minutes)?.label + : undefined + + return ( + + + + + + `Time to deploy: ${val}`} />} + /> + + {medianBucket && ( + + )} + + + ) +} diff --git a/apps/web/src/components/overview.tsx b/apps/web/src/components/overview.tsx index b8952d6..82de661 100644 --- a/apps/web/src/components/overview.tsx +++ b/apps/web/src/components/overview.tsx @@ -1,11 +1,13 @@ import { motion } from 'motion/react' import { CountUp } from '@/components/count-up' +import { Histogram } from '@/components/charts/histogram' import { TimelineArea } from '@/components/charts/timeline-area' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { useStats } from '@/hooks/use-stats' import { useTimeline } from '@/hooks/use-timeline' +import { useTimeToDeploy } from '@/hooks/use-time-to-deploy' import { formatNumber, tierLabel } from '@/lib/format' import type { Stats } from '@/lib/types' @@ -90,6 +92,47 @@ function ActivityCard({ contracts }: { contracts?: number }) { ) } +function TimeToDeployCard() { + const { data, isPending } = useTimeToDeploy() + + return ( + + + + Time from first commit in window to first on-chain deploy (30 min buckets) + +
+ + {isPending ? ( + + ) : data?.median_minutes !== null && data?.median_minutes !== undefined ? ( + Median: {data.median_minutes}m + ) : ( + No deploy data + )} + + {data && data.total_projects > 0 && ( + + across {data.total_projects} projects with deployments + + )} +
+
+ + {isPending ? ( + + ) : data && data.buckets.length > 0 ? ( + + ) : ( +
+ No deploy timing records available yet. +
+ )} +
+
+ ) +} + export function Overview() { const { data: stats } = useStats() @@ -104,6 +147,7 @@ export function Overview() {
+
{SECONDARY.map(({ key, label }) => ( diff --git a/apps/web/src/hooks/use-time-to-deploy.ts b/apps/web/src/hooks/use-time-to-deploy.ts new file mode 100644 index 0000000..2b9dd6c --- /dev/null +++ b/apps/web/src/hooks/use-time-to-deploy.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' + +import { apiGet } from '@/lib/api' +import type { TimeToDeployData } from '@/lib/types' + +export function useTimeToDeploy() { + return useQuery({ + queryKey: ['time-to-deploy'], + queryFn: () => apiGet('/time-to-deploy'), + }) +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 372ebef..05e5311 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -154,3 +154,17 @@ export interface CountryCount { country: string contributors: number } + +export interface DeployBucket { + bucket_index: number + min_minutes: number + max_minutes: number + label: string + count: number +} + +export interface TimeToDeployData { + total_projects: number + median_minutes: number | null + buckets: DeployBucket[] +}