feat(forgejo-metrics): enhance heatmap metrics to include line contributions and adjust time frame to last 6 months

This commit is contained in:
null 2026-05-22 16:04:32 -05:00
parent 99965330b5
commit bbfde53fe9
8 changed files with 208 additions and 49 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
from typing import TYPE_CHECKING
from uuid import UUID
@ -16,9 +17,11 @@ from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
from app.core.time import utcnow
from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
from app.services.forgejo_client import get_forgejo_client
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
@ -261,30 +264,31 @@ async def get_forgejo_metrics(
"/heatmap",
response_model=HeatmapResponse,
summary="Forgejo issue activity heatmap",
description="Daily issue open+close event counts for the last 365 days, scoped to the caller's organisation.",
description="Daily issue open+close event counts for the last 6 months, scoped to the caller's organisation.",
)
async def get_forgejo_heatmap(
organization_id: UUID | None = Query(None, description="Filter by organisation ID"),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> HeatmapResponse:
"""Return per-day issue event counts (created + closed) for the last 365 days."""
"""Return per-day issue event counts and total line contributions for the last 6 months."""
if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since = utcnow() - timedelta(days=365)
since = utcnow() - timedelta(days=183)
repo_ids_result = (
# Fetch repos with their connections in one query
repos_with_conns = (
await session.exec(
select(ForgejoRepository.id).where(
ForgejoRepository.organization_id == ctx.organization.id,
)
select(ForgejoRepository, ForgejoConnection)
.join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id)
.where(ForgejoRepository.organization_id == ctx.organization.id)
)
).all()
if not repo_ids_result:
if not repos_with_conns:
return HeatmapResponse(days=[], max_count=0)
repo_ids = list(repo_ids_result)
repo_ids = [repo.id for repo, _ in repos_with_conns]
counts: dict[str, int] = {}
# Issues created per day
@ -325,9 +329,40 @@ async def get_forgejo_heatmap(
key = str(day)
counts[key] = counts.get(key, 0) + int(cnt)
days = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())]
max_count = max((d.count for d in days), default=0)
return HeatmapResponse(days=days, max_count=max_count)
days_list = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())]
max_count = max((d.count for d in days_list), default=0)
# Fetch contributor line stats concurrently from Forgejo API
since_ts = since.timestamp()
async def _repo_line_stats(repo: ForgejoRepository, conn: ForgejoConnection) -> tuple[int, int, bool]:
try:
async with get_forgejo_client(conn) as client:
contributors, has_data = await client.get_contributor_stats(repo.owner, repo.repo)
adds = dels = 0
for contributor in contributors:
for week in contributor.get("weeks", []):
if (week.get("w") or 0) >= since_ts:
adds += week.get("a", 0) or 0
dels += week.get("d", 0) or 0
return adds, dels, has_data
except Exception:
return 0, 0, False
line_results = await asyncio.gather(
*[_repo_line_stats(repo, conn) for repo, conn in repos_with_conns]
)
total_additions = sum(a for a, _, _ in line_results)
total_deletions = sum(d for _, d, _ in line_results)
has_line_stats = all(ok for _, _, ok in line_results)
return HeatmapResponse(
days=days_list,
max_count=max_count,
total_additions=total_additions,
total_deletions=total_deletions,
has_line_stats=has_line_stats,
)
def _zeroed_metrics() -> MetricsResponse:

View File

@ -129,10 +129,13 @@ class HeatmapDay(SQLModel):
class HeatmapResponse(SQLModel):
"""Issue activity heatmap for the last 365 days."""
"""Issue activity heatmap for the last 6 months."""
days: list[HeatmapDay]
max_count: int
total_additions: int = 0
total_deletions: int = 0
has_line_stats: bool = False # False when Forgejo is still computing stats (HTTP 202)
class MetricsResponse(SQLModel):

View File

@ -350,6 +350,36 @@ class ForgejoAPIClient:
return list(data)
async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]:
"""Fetch per-contributor weekly stats for a repository.
Returns (contributors, has_data). On the first call Forgejo may return
HTTP 202 ("computing") we wait 2 s and retry once so the stats are
available on the next dashboard load even if not this one.
Each contributor has a ``weeks`` array with ``w`` (Unix timestamp of
week start), ``a`` (additions), and ``d`` (deletions).
"""
import asyncio as _asyncio
client = await self._get_client()
url = f"/api/v1/repos/{owner}/{repo}/stats/contributors"
response = await client.get(url)
if response.status_code == 202:
# Forgejo is computing — wait briefly then try once more
await _asyncio.sleep(2)
response = await client.get(url)
if response.status_code == 202:
return [], False # still computing after retry
if response.status_code == 404:
return [], True # no data, but not a 202 — treat as "has_data"
response.raise_for_status()
data = response.json()
return (data if isinstance(data, list) else []), True
def get_forgejo_client(
connection: object,
) -> ForgejoAPIClient:

View File

@ -522,7 +522,7 @@ export default function DashboardPage() {
},
});
const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number } | null, Error>({
const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number; total_additions: number; total_deletions: number; has_line_stats: boolean } | null, Error>({
queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId],
enabled: Boolean(
isSignedIn &&
@ -1240,6 +1240,9 @@ export default function DashboardPage() {
<ForgejoHeatmap
days={forgejoHeatmapQuery.data?.days ?? []}
maxCount={forgejoHeatmapQuery.data?.max_count ?? 0}
totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0}
totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0}
hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false}
isLoading={forgejoHeatmapQuery.isLoading}
/>
</section>

View File

@ -592,11 +592,23 @@ function UsageWindowBar({ label, pct, resetInMs, badge }: UsageWindowBarProps) {
);
}
function fmtElapsed(since: Date, now: number): string {
const s = Math.round((now - since.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}
function UsageStrip({ credentialId, provider }: { credentialId: string; provider: string }) {
const [usage, setUsage] = useState<ProviderUsageLiveRead | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | null>(null);
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const fetchUsage = useCallback(
async (refresh = false) => {
@ -777,7 +789,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
)}
<div className="flex items-center justify-between text-[11px] text-muted">
{lastFetched && (
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>
<span>Updated {fmtElapsed(lastFetched, now)}</span>
)}
</div>
</div>
@ -828,7 +840,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
<div className="flex items-center justify-between text-[11px] text-muted">
{lastFetched && (
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>
<span>Updated {fmtElapsed(lastFetched, now)}</span>
)}
<button
type="button"

View File

@ -6,26 +6,29 @@ import type { ForgejoHeatmapDay } from "@/lib/api-forgejo";
interface ForgejoHeatmapProps {
days: ForgejoHeatmapDay[];
maxCount: number;
totalAdditions?: number;
totalDeletions?: number;
hasLineStats?: boolean;
isLoading?: boolean;
}
// Layout constants — match Forgejo's contribution graph
const CELL = 10;
// Layout constants — 6-month view with larger cells
const CELL = 13;
const GAP = 3;
const STRIDE = CELL + GAP; // 13px per cell
const WEEKS = 53;
const STRIDE = CELL + GAP; // 16px per cell
const WEEKS = 27;
const LEFT = 28; // width reserved for Mon/Wed/Fri labels
const TOP = 18; // height reserved for month labels
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
// Forgejo dark-mode green palette (levels 04)
// Violet/purple palette — fixed rgba so they render in SVG regardless of CSS var support
const LEVEL_FILL = [
"var(--surface-strong)", // 0 — no activity
"rgba(52,211,153,0.22)", // 1
"rgba(52,211,153,0.46)", // 2
"rgba(52,211,153,0.70)", // 3
"var(--success)", // 4
"rgba(139,92,246,0.07)", // 0 — empty (barely-there grid so cells are visible)
"rgba(139,92,246,0.28)", // 1
"rgba(139,92,246,0.52)", // 2
"rgba(139,92,246,0.75)", // 3
"rgba(139,92,246,1.0)", // 4 — full violet-500
];
function toLevel(count: number): number {
@ -43,21 +46,28 @@ function isoDate(d: Date): string {
return `${y}-${m}-${day}`;
}
function fmtLines(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return n.toLocaleString();
}
export function ForgejoHeatmap({
days,
maxCount: _maxCount,
totalAdditions = 0,
totalDeletions = 0,
hasLineStats = false,
isLoading = false,
}: ForgejoHeatmapProps) {
const { weeks, monthLabels } = useMemo(() => {
const data = new Map<string, number>(days.map((d) => [d.date, d.count]));
// Start on the Sunday 52 weeks before the current week's Sunday,
// so the last column always contains today and future cells are clipped.
const today = new Date();
today.setHours(0, 0, 0, 0);
const start = new Date(today);
start.setDate(start.getDate() - start.getDay()); // rewind to this week's Sunday
start.setDate(start.getDate() - (WEEKS - 1) * 7); // go back 52 more weeks
start.setDate(start.getDate() - start.getDay());
start.setDate(start.getDate() - (WEEKS - 1) * 7);
type Cell = { date: string; count: number; future: boolean };
const builtWeeks: Cell[][] = [];
@ -88,15 +98,21 @@ export function ForgejoHeatmap({
if (isLoading) {
return (
<div
className="animate-pulse rounded"
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }}
/>
<div className="space-y-3">
<div
className="animate-pulse rounded"
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }}
/>
<div className="h-10 animate-pulse rounded" style={{ background: "var(--surface-strong)" }} />
</div>
);
}
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
return (
<div>
<div className="space-y-3">
{/* Heatmap grid */}
<div style={{ overflowX: "auto" }} className="flex justify-center">
<svg
width={svgW}
@ -111,30 +127,26 @@ export function ForgejoHeatmap({
x={LEFT + weekIdx * STRIDE}
y={11}
fontSize={10}
fill="currentColor"
className="text-muted"
style={{ fill: "var(--text-muted, #9ca3af)" }}
>
{label}
</text>
))}
{/* Day-of-week labels: Mon=row1, Wed=row3, Fri=row5
y = cell top + CELL - 2 puts the baseline near the bottom of the
10px cell, which reads as vertically centred for a 10px font. */}
{/* Day-of-week labels */}
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
<text
key={label}
x={0}
y={TOP + (i * 2 + 1) * STRIDE + CELL - 2}
fontSize={10}
fill="currentColor"
className="text-muted"
style={{ fill: "var(--text-muted, #9ca3af)" }}
>
{label}
</text>
))}
{/* Cells */}
{/* Cells — use style.fill so rgba + CSS vars both resolve correctly */}
{weeks.map((week, wi) =>
week.map((cell, di) =>
cell.future ? null : (
@ -144,8 +156,8 @@ export function ForgejoHeatmap({
y={TOP + di * STRIDE}
width={CELL}
height={CELL}
rx={2}
fill={LEVEL_FILL[toLevel(cell.count)]}
rx={3}
style={{ fill: LEVEL_FILL[toLevel(cell.count)] }}
>
<title>
{cell.date}
@ -161,19 +173,58 @@ export function ForgejoHeatmap({
</div>
{/* Legend */}
<div className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted">
<div className="flex items-center justify-center gap-1.5 text-xs text-muted">
<span>Less</span>
{LEVEL_FILL.map((fill, i) => (
<div
key={i}
style={{ width: CELL, height: CELL, borderRadius: 2, background: fill, flexShrink: 0 }}
style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }}
/>
))}
<span>More</span>
</div>
<p className="mt-1.5 text-center text-[10px] text-muted opacity-70">
{days.reduce((sum, d) => sum + d.count, 0).toLocaleString()} contributions across all tracked repositories in the last 12 months.
{/* Contributions summary — large violet number, readable label */}
<p className="text-center">
<span
className="text-2xl font-bold tabular-nums"
style={{ color: "rgba(139,92,246,1)" }}
>
{totalEvents.toLocaleString()}
</span>
<span className="ml-1.5 text-sm font-medium text-muted">
contributions across all tracked repositories in the last 6 months
</span>
</p>
{/* Line contribution stats */}
{hasLineStats ? (
<div className="flex items-center justify-center gap-8 pt-1">
<div className="flex flex-col items-center gap-0.5">
<span
className="text-2xl font-bold tabular-nums"
style={{ color: "rgba(52,211,153,1)" }}
>
+{fmtLines(totalAdditions)}
</span>
<span className="text-xs font-medium text-muted">lines added</span>
</div>
<div className="h-10 w-px" style={{ background: "var(--border)" }} />
<div className="flex flex-col items-center gap-0.5">
<span
className="text-2xl font-bold tabular-nums"
style={{ color: "rgba(248,113,113,1)" }}
>
-{fmtLines(totalDeletions)}
</span>
<span className="text-xs font-medium text-muted">lines removed</span>
</div>
</div>
) : (
<p className="text-center text-xs text-muted opacity-60">
Line stats syncing with Forgejo
</p>
)}
</div>
);
}

View File

@ -242,6 +242,12 @@ function ProviderInlineStatus({
);
}
function fmtElapsed(since: Date, now: number): string {
const s = Math.round((now - since.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}
export function ProviderNavbarStatus() {
const { isSignedIn } = useAuth();
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
@ -249,6 +255,13 @@ export function ProviderNavbarStatus() {
Record<string, ProviderUsageLiveRead | null>
>({});
const [isUsageLoading, setIsUsageLoading] = useState(false);
const [lastFetchedAt, setLastFetchedAt] = useState<Date | null>(null);
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const usageCredentials = useMemo(() => {
return credentials
@ -321,6 +334,7 @@ export function ProviderNavbarStatus() {
);
if (!cancelled) {
setUsageByCredentialId(Object.fromEntries(pairs));
setLastFetchedAt(new Date());
setIsUsageLoading(false);
}
};
@ -364,6 +378,14 @@ export function ProviderNavbarStatus() {
<ProviderInlineStatus item={item} />
</div>
))}
{lastFetchedAt && (
<>
<span className="h-3.5 w-px shrink-0 bg-[color:var(--border)]" />
<span className="tabular-nums text-[color:var(--text-quiet)]">
Updated {fmtElapsed(lastFetchedAt, now)}
</span>
</>
)}
</div>
<Popover>
<PopoverTrigger asChild>

View File

@ -611,6 +611,9 @@ export interface ForgejoHeatmapDay {
export interface ForgejoHeatmapResponse {
days: ForgejoHeatmapDay[];
max_count: number;
total_additions: number;
total_deletions: number;
has_line_stats: boolean;
}
export async function getForgejoHeatmap(params?: {