Skip to content
Open
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
15 changes: 13 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Backend (moved to private repo: SolFoundry/solfoundry-api)
backend/
# Backend (primary API in SolFoundry/solfoundry-api); in-repo stubs tracked only under:
/backend/**
!/backend/main.py
!/backend/requirements.txt
!/backend/requirements-dev.txt
!/backend/pytest.ini
!/backend/routers/
!/backend/routers/**
!/backend/tests/
!/backend/tests/**

# Environment & Secrets
.env
Expand Down Expand Up @@ -27,6 +35,8 @@ eggs/
.eggs/
lib/
lib64/
!/frontend/src/lib/
!/frontend/src/lib/**
parts/
sdist/
var/
Expand All @@ -40,6 +50,7 @@ wheels/
venv/
ENV/
env/
myenv/

# Node
node_modules/
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.backend
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
37 changes: 37 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""SolFoundry FastAPI entrypoint (monorepo stub; production API lives in solfoundry-api)."""

from typing import Any, Dict

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from routers.analytics import router as analytics_router

app = FastAPI(
title="SolFoundry API",
description="Bounty analytics and health endpoints for local / Docker dev.",
version="0.1.0",
)

app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(analytics_router, prefix="/api")


@app.get("/")
def read_root() -> Dict[str, str]:
return {"message": "Welcome to the Bounty Analytics Dashboard API"}


@app.get("/health")
def health() -> Dict[str, Any]:
return {"status": "ok"}
4 changes: 4 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_*.py
pythonpath = .
3 changes: 3 additions & 0 deletions backend/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0
httpx>=0.27
4 changes: 4 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi>=0.110,<1
uvicorn[standard]>=0.27,<1
pydantic>=2.5,<3
fpdf2>=2.7,<3
1 change: 1 addition & 0 deletions backend/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""API routers."""
148 changes: 148 additions & 0 deletions backend/routers/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Bounty analytics API — seed data until wired to solfoundry-api."""

from __future__ import annotations

import csv
import io
from datetime import date, timedelta
from typing import List

from fastapi import APIRouter
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field
from fpdf import FPDF

router = APIRouter(prefix="/analytics", tags=["analytics"])


def _series_dates(days: int = 28) -> List[date]:
end = date.today()
return [end - timedelta(days=i) for i in range(days - 1, -1, -1)]


def seed_bounty_volume() -> List[dict]:
out = []
for i, d in enumerate(_series_dates(28)):
# Simple wave + drift for plausible chart
base = 12 + (i % 7) * 2 + (i // 7)
out.append({"date": d.isoformat(), "count": base})
return out


def seed_payouts() -> List[dict]:
out = []
for i, d in enumerate(_series_dates(28)):
amount = 1500.0 + i * 120.5 + (i % 5) * 200.0
out.append({"date": d.isoformat(), "amountUsd": round(amount, 2)})
return out


class WeeklyGrowth(BaseModel):
week_start: str
new_contributors: int


class ContributorAnalytics(BaseModel):
new_contributors_last_30d: int = Field(..., ge=0)
active_contributors_last_30d: int = Field(..., ge=0)
retention_rate: float = Field(..., ge=0.0, le=1.0, description="Fraction retained vs prior period")
weekly_growth: List[WeeklyGrowth]


def seed_contributors() -> ContributorAnalytics:
return ContributorAnalytics(
new_contributors_last_30d=47,
active_contributors_last_30d=312,
retention_rate=0.72,
weekly_growth=[
WeeklyGrowth(week_start="2026-03-10", new_contributors=8),
WeeklyGrowth(week_start="2026-03-17", new_contributors=11),
WeeklyGrowth(week_start="2026-03-24", new_contributors=9),
WeeklyGrowth(week_start="2026-03-31", new_contributors=14),
],
)


@router.get("/bounty-volume", response_model=List[dict])
def get_bounty_volume() -> List[dict]:
return seed_bounty_volume()


@router.get("/payouts", response_model=List[dict])
def get_payouts() -> List[dict]:
return seed_payouts()


@router.get("/contributors", response_model=ContributorAnalytics)
def get_contributors() -> ContributorAnalytics:
return seed_contributors()


@router.get("/reports/export.csv")
def export_csv() -> StreamingResponse:
vol = seed_bounty_volume()
pay = seed_payouts()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["section", "date", "metric", "value"])
for row in vol:
w.writerow(["bounty_volume", row["date"], "count", row["count"]])
for row in pay:
w.writerow(["payouts", row["date"], "amount_usd", row["amountUsd"]])
c = seed_contributors()
w.writerow(["summary", "", "new_contributors_30d", c.new_contributors_last_30d])
w.writerow(["summary", "", "active_contributors_30d", c.active_contributors_last_30d])
w.writerow(["summary", "", "retention_rate", c.retention_rate])

buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="bounty-analytics-report.csv"',
},
)


@router.get("/reports/export.pdf")
def export_pdf() -> Response:
vol = seed_bounty_volume()
pay = seed_payouts()
c = seed_contributors()

pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=12)
pdf.add_page()
pdf.set_font("Helvetica", "B", 16)
pdf.cell(0, 10, "SolFoundry - Bounty analytics report", ln=True)
pdf.set_font("Helvetica", size=11)
pdf.cell(0, 8, f"New contributors (30d): {c.new_contributors_last_30d}", ln=True)
pdf.cell(0, 8, f"Active contributors (30d): {c.active_contributors_last_30d}", ln=True)
pdf.cell(0, 8, f"Retention rate: {c.retention_rate:.0%}", ln=True)
pdf.ln(4)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "Recent bounty volume (last 7 days)", ln=True)
pdf.set_font("Helvetica", size=10)
for row in vol[-7:]:
pdf.cell(0, 6, f" {row['date']}: {row['count']} bounties", ln=True)
pdf.ln(2)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "Recent payouts (USD, last 7 days)", ln=True)
pdf.set_font("Helvetica", size=10)
for row in pay[-7:]:
pdf.cell(0, 6, f" {row['date']}: ${row['amountUsd']:,.2f}", ln=True)

raw = pdf.output(dest="S")
if isinstance(raw, str):
body = raw.encode("latin-1")
elif isinstance(raw, (bytes, bytearray)):
body = bytes(raw)
else:
body = bytes(raw)
return Response(
content=body,
media_type="application/pdf",
headers={
"Content-Disposition": 'attachment; filename="bounty-analytics-report.pdf"',
},
)
68 changes: 68 additions & 0 deletions backend/tests/test_analytics_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""API tests for bounty analytics endpoints (seed data)."""

from __future__ import annotations

from typing import Any, Dict, List

import pytest
from fastapi.testclient import TestClient

from main import app


@pytest.fixture
def client() -> TestClient:
return TestClient(app)


def test_health(client: TestClient) -> None:
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}


def test_bounty_volume_shape(client: TestClient) -> None:
r = client.get("/api/analytics/bounty-volume")
assert r.status_code == 200
data: List[Dict[str, Any]] = r.json()
assert len(data) >= 7
row = data[0]
assert "date" in row and "count" in row
assert isinstance(row["count"], int)


def test_payouts_shape(client: TestClient) -> None:
r = client.get("/api/analytics/payouts")
assert r.status_code == 200
data: List[Dict[str, Any]] = r.json()
assert len(data) >= 7
assert "amountUsd" in data[0]


def test_contributors_shape(client: TestClient) -> None:
r = client.get("/api/analytics/contributors")
assert r.status_code == 200
body: Dict[str, Any] = r.json()
assert "new_contributors_last_30d" in body
assert "retention_rate" in body
assert 0 <= float(body["retention_rate"]) <= 1
assert isinstance(body["weekly_growth"], list)


def test_export_csv_headers(client: TestClient) -> None:
r = client.get("/api/analytics/reports/export.csv")
assert r.status_code == 200
assert "text/csv" in r.headers.get("content-type", "")
assert "attachment" in r.headers.get("content-disposition", "")
text = r.text
assert "bounty_volume" in text or "section" in text


def test_export_pdf_content_type(client: TestClient) -> None:
r = client.get("/api/analytics/reports/export.pdf")
assert r.status_code == 200
assert r.headers.get("content-type") == "application/pdf"
assert r.content[:4] == b"%PDF"
assert "attachment" in r.headers.get("content-disposition", "")


Binary file added bun.lockb
Binary file not shown.
54 changes: 54 additions & 0 deletions docs/features/bounty-analytics-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Bounty Analytics Dashboard

> Last updated: 2026-04-08

## Overview

The Bounty Analytics Dashboard surfaces time-series bounty volume and payout data, contributor growth and retention metrics, and downloadable CSV/PDF reports. Data is **seeded in the monorepo FastAPI app** until the primary API (`solfoundry-api`) exposes production metrics.

## Architecture

| Layer | Location |
|-------|----------|
| API | `backend/main.py`, `backend/routers/analytics.py` |
| UI | `frontend/src/pages/BountyAnalyticsPage.tsx`, route `/analytics` |
| Client helpers | `frontend/src/api/analytics.ts`, `frontend/src/hooks/useBountyAnalytics.ts` |

## API (FastAPI)

Base path: `/api` (proxied from Vite dev server on port 5173 to backend port 8000).

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/analytics/bounty-volume` | JSON array of `{ date, count }` (daily, seed) |
| GET | `/api/analytics/payouts` | JSON array of `{ date, amountUsd }` |
| GET | `/api/analytics/contributors` | JSON: `new_contributors_last_30d`, `active_contributors_last_30d`, `retention_rate`, `weekly_growth[]` |
| GET | `/api/analytics/reports/export.csv` | CSV attachment |
| GET | `/api/analytics/reports/export.pdf` | PDF attachment |

Also: `GET /health` (Docker healthcheck), `GET /` welcome JSON.

## Frontend

- Navigate to **`/analytics`** (link in the main navbar: “Analytics”).
- Charts use **Recharts**; exports use anchor `href` to the export endpoints above.

## Configuration

| Variable | Purpose |
|----------|---------|
| `VITE_API_URL` | Optional absolute API origin for production builds; leave unset in dev to use the Vite proxy |

## Local development

1. Backend: `cd backend && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`
2. Frontend: `cd frontend && npm run dev` (opens `http://localhost:5173`, proxies `/api` to 8000)

## Testing

- Python: `cd backend && pip install -r requirements-dev.txt && pytest`
- Frontend (configured suites): `cd frontend && npm test`

## References

- Closes issue #859
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const ProfilePage = React.lazy(() => import('./pages/ProfilePage').then((m) => (
const GitHubCallbackPage = React.lazy(() => import('./pages/GitHubCallbackPage').then((m) => ({ default: m.GitHubCallbackPage })));
const BountiesPage = React.lazy(() => import('./pages/BountiesPage').then((m) => ({ default: m.BountiesPage })));
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage').then((m) => ({ default: m.NotFoundPage })));
const BountyAnalyticsPage = React.lazy(() =>
import('./pages/BountyAnalyticsPage').then((m) => ({ default: m.BountyAnalyticsPage })),
);

function PageLoader() {
return (
Expand Down Expand Up @@ -45,6 +48,7 @@ export default function App() {
}
/>
<Route path="/bounties" element={<BountiesPage />} />
<Route path="/analytics" element={<BountyAnalyticsPage />} />
<Route path="/bounties/:id" element={<BountyDetailPage />} />
<Route path="/auth/github/callback" element={<GitHubCallbackPage />} />
<Route path="*" element={<NotFoundPage />} />
Expand Down
Loading