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 (
+