Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/hackproof/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/hackproof/hackathon.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Hackathon(BaseModel):
window: Window
awards: list[Award] = []
discovery: Discovery
hidden: list[str] = []


@lru_cache
Expand All @@ -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)
77 changes: 76 additions & 1 deletion apps/api/src/hackproof/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}
7 changes: 7 additions & 0 deletions apps/api/src/hackproof/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Stats,
Tier,
TimelinePoint,
TimeToDeployResponse,
WasmUpload,
)

Expand All @@ -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()]
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/hackproof/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
97 changes: 97 additions & 0 deletions apps/api/tests/test_time_to_deploy.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions apps/web/src/components/charts/histogram.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ChartContainer config={config} className={className}>
<BarChart data={buckets} margin={{ top: 24, right: 16, left: 0, bottom: 5 }}>
<CartesianGrid vertical={false} strokeWidth={1} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
tick={{ fontSize: 11 }}
/>
<YAxis tickLine={false} axisLine={false} width={36} allowDecimals={false} />
<ChartTooltip
cursor={{ fill: 'var(--muted)', opacity: 0.3 }}
content={<ChartTooltipContent labelFormatter={(val) => `Time to deploy: ${val}`} />}
/>
<Bar dataKey="count" fill="var(--chart-1)" radius={[4, 4, 0, 0]} animationDuration={1000} />
{medianBucket && (
<ReferenceLine
x={medianBucket}
stroke="var(--chart-2)"
strokeDasharray="3 3"
strokeWidth={2}
label={{
value: `Median ${median}m`,
position: 'top',
fill: 'var(--foreground)',
fontSize: 11,
}}
/>
)}
</BarChart>
</ChartContainer>
)
}
44 changes: 44 additions & 0 deletions apps/web/src/components/overview.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -90,6 +92,47 @@ function ActivityCard({ contracts }: { contracts?: number }) {
)
}

function TimeToDeployCard() {
const { data, isPending } = useTimeToDeploy()

return (
<Card className="lg:col-span-3">
<CardHeader>
<CardDescription>
Time from first commit in window to first on-chain deploy (30 min buckets)
</CardDescription>
<div className="flex flex-wrap items-baseline gap-4">
<CardTitle className="text-3xl sm:text-4xl">
{isPending ? (
<Skeleton className="h-9 w-32" />
) : data?.median_minutes !== null && data?.median_minutes !== undefined ? (
<span>Median: {data.median_minutes}m</span>
) : (
<span>No deploy data</span>
)}
</CardTitle>
{data && data.total_projects > 0 && (
<span className="text-xs text-muted-foreground">
across {data.total_projects} projects with deployments
</span>
)}
</div>
</CardHeader>
<CardContent>
{isPending ? (
<Skeleton className="h-52 w-full" />
) : data && data.buckets.length > 0 ? (
<Histogram buckets={data.buckets} median={data.median_minutes} className="h-52 w-full" />
) : (
<div className="flex h-52 items-center justify-center text-sm text-muted-foreground">
No deploy timing records available yet.
</div>
)}
</CardContent>
</Card>
)
}

export function Overview() {
const { data: stats } = useStats()

Expand All @@ -104,6 +147,7 @@ export function Overview() {
<div className="grid gap-4 lg:grid-cols-3">
<ActivityCard contracts={stats?.contracts} />
<ProjectsCard stats={stats} />
<TimeToDeployCard />
</div>
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-xl border bg-card px-5 py-4 sm:grid-cols-3 lg:grid-cols-5">
{SECONDARY.map(({ key, label }) => (
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/hooks/use-time-to-deploy.ts
Original file line number Diff line number Diff line change
@@ -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<TimeToDeployData>('/time-to-deploy'),
})
}
Loading