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 from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID 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.core.time import utcnow
from app.db.session import get_session from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink 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_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
from app.services.forgejo_client import get_forgejo_client
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@ -261,30 +264,31 @@ async def get_forgejo_metrics(
"/heatmap", "/heatmap",
response_model=HeatmapResponse, response_model=HeatmapResponse,
summary="Forgejo issue activity heatmap", 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( async def get_forgejo_heatmap(
organization_id: UUID | None = Query(None, description="Filter by organisation ID"), organization_id: UUID | None = Query(None, description="Filter by organisation ID"),
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> HeatmapResponse: ) -> 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: if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 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( await session.exec(
select(ForgejoRepository.id).where( select(ForgejoRepository, ForgejoConnection)
ForgejoRepository.organization_id == ctx.organization.id, .join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id)
) .where(ForgejoRepository.organization_id == ctx.organization.id)
) )
).all() ).all()
if not repo_ids_result: if not repos_with_conns:
return HeatmapResponse(days=[], max_count=0) 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] = {} counts: dict[str, int] = {}
# Issues created per day # Issues created per day
@ -325,9 +329,40 @@ async def get_forgejo_heatmap(
key = str(day) key = str(day)
counts[key] = counts.get(key, 0) + int(cnt) counts[key] = counts.get(key, 0) + int(cnt)
days = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())] days_list = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())]
max_count = max((d.count for d in days), default=0) max_count = max((d.count for d in days_list), default=0)
return HeatmapResponse(days=days, max_count=max_count)
# 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: def _zeroed_metrics() -> MetricsResponse:

View File

@ -129,10 +129,13 @@ class HeatmapDay(SQLModel):
class HeatmapResponse(SQLModel): class HeatmapResponse(SQLModel):
"""Issue activity heatmap for the last 365 days.""" """Issue activity heatmap for the last 6 months."""
days: list[HeatmapDay] days: list[HeatmapDay]
max_count: int 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): class MetricsResponse(SQLModel):

View File

@ -350,6 +350,36 @@ class ForgejoAPIClient:
return list(data) 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( def get_forgejo_client(
connection: object, connection: object,
) -> ForgejoAPIClient: ) -> 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], queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId],
enabled: Boolean( enabled: Boolean(
isSignedIn && isSignedIn &&
@ -1240,6 +1240,9 @@ export default function DashboardPage() {
<ForgejoHeatmap <ForgejoHeatmap
days={forgejoHeatmapQuery.data?.days ?? []} days={forgejoHeatmapQuery.data?.days ?? []}
maxCount={forgejoHeatmapQuery.data?.max_count ?? 0} 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} isLoading={forgejoHeatmapQuery.isLoading}
/> />
</section> </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 }) { function UsageStrip({ credentialId, provider }: { credentialId: string; provider: string }) {
const [usage, setUsage] = useState<ProviderUsageLiveRead | null>(null); const [usage, setUsage] = useState<ProviderUsageLiveRead | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | 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( const fetchUsage = useCallback(
async (refresh = false) => { 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"> <div className="flex items-center justify-between text-[11px] text-muted">
{lastFetched && ( {lastFetched && (
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span> <span>Updated {fmtElapsed(lastFetched, now)}</span>
)} )}
</div> </div>
</div> </div>
@ -828,7 +840,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
<div className="flex items-center justify-between text-[11px] text-muted"> <div className="flex items-center justify-between text-[11px] text-muted">
{lastFetched && ( {lastFetched && (
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span> <span>Updated {fmtElapsed(lastFetched, now)}</span>
)} )}
<button <button
type="button" type="button"

View File

@ -6,26 +6,29 @@ import type { ForgejoHeatmapDay } from "@/lib/api-forgejo";
interface ForgejoHeatmapProps { interface ForgejoHeatmapProps {
days: ForgejoHeatmapDay[]; days: ForgejoHeatmapDay[];
maxCount: number; maxCount: number;
totalAdditions?: number;
totalDeletions?: number;
hasLineStats?: boolean;
isLoading?: boolean; isLoading?: boolean;
} }
// Layout constants — match Forgejo's contribution graph // Layout constants — 6-month view with larger cells
const CELL = 10; const CELL = 13;
const GAP = 3; const GAP = 3;
const STRIDE = CELL + GAP; // 13px per cell const STRIDE = CELL + GAP; // 16px per cell
const WEEKS = 53; const WEEKS = 27;
const LEFT = 28; // width reserved for Mon/Wed/Fri labels const LEFT = 28; // width reserved for Mon/Wed/Fri labels
const TOP = 18; // height reserved for month labels const TOP = 18; // height reserved for month labels
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; 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 = [ const LEVEL_FILL = [
"var(--surface-strong)", // 0 — no activity "rgba(139,92,246,0.07)", // 0 — empty (barely-there grid so cells are visible)
"rgba(52,211,153,0.22)", // 1 "rgba(139,92,246,0.28)", // 1
"rgba(52,211,153,0.46)", // 2 "rgba(139,92,246,0.52)", // 2
"rgba(52,211,153,0.70)", // 3 "rgba(139,92,246,0.75)", // 3
"var(--success)", // 4 "rgba(139,92,246,1.0)", // 4 — full violet-500
]; ];
function toLevel(count: number): number { function toLevel(count: number): number {
@ -43,21 +46,28 @@ function isoDate(d: Date): string {
return `${y}-${m}-${day}`; 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({ export function ForgejoHeatmap({
days, days,
maxCount: _maxCount, maxCount: _maxCount,
totalAdditions = 0,
totalDeletions = 0,
hasLineStats = false,
isLoading = false, isLoading = false,
}: ForgejoHeatmapProps) { }: ForgejoHeatmapProps) {
const { weeks, monthLabels } = useMemo(() => { const { weeks, monthLabels } = useMemo(() => {
const data = new Map<string, number>(days.map((d) => [d.date, d.count])); 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(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const start = new Date(today); const start = new Date(today);
start.setDate(start.getDate() - start.getDay()); // rewind to this week's Sunday start.setDate(start.getDate() - start.getDay());
start.setDate(start.getDate() - (WEEKS - 1) * 7); // go back 52 more weeks start.setDate(start.getDate() - (WEEKS - 1) * 7);
type Cell = { date: string; count: number; future: boolean }; type Cell = { date: string; count: number; future: boolean };
const builtWeeks: Cell[][] = []; const builtWeeks: Cell[][] = [];
@ -88,15 +98,21 @@ export function ForgejoHeatmap({
if (isLoading) { if (isLoading) {
return ( return (
<div <div className="space-y-3">
className="animate-pulse rounded" <div
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }} 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 ( return (
<div> <div className="space-y-3">
{/* Heatmap grid */}
<div style={{ overflowX: "auto" }} className="flex justify-center"> <div style={{ overflowX: "auto" }} className="flex justify-center">
<svg <svg
width={svgW} width={svgW}
@ -111,30 +127,26 @@ export function ForgejoHeatmap({
x={LEFT + weekIdx * STRIDE} x={LEFT + weekIdx * STRIDE}
y={11} y={11}
fontSize={10} fontSize={10}
fill="currentColor" style={{ fill: "var(--text-muted, #9ca3af)" }}
className="text-muted"
> >
{label} {label}
</text> </text>
))} ))}
{/* Day-of-week labels: Mon=row1, Wed=row3, Fri=row5 {/* Day-of-week labels */}
y = cell top + CELL - 2 puts the baseline near the bottom of the
10px cell, which reads as vertically centred for a 10px font. */}
{(["Mon", "Wed", "Fri"] as const).map((label, i) => ( {(["Mon", "Wed", "Fri"] as const).map((label, i) => (
<text <text
key={label} key={label}
x={0} x={0}
y={TOP + (i * 2 + 1) * STRIDE + CELL - 2} y={TOP + (i * 2 + 1) * STRIDE + CELL - 2}
fontSize={10} fontSize={10}
fill="currentColor" style={{ fill: "var(--text-muted, #9ca3af)" }}
className="text-muted"
> >
{label} {label}
</text> </text>
))} ))}
{/* Cells */} {/* Cells — use style.fill so rgba + CSS vars both resolve correctly */}
{weeks.map((week, wi) => {weeks.map((week, wi) =>
week.map((cell, di) => week.map((cell, di) =>
cell.future ? null : ( cell.future ? null : (
@ -144,8 +156,8 @@ export function ForgejoHeatmap({
y={TOP + di * STRIDE} y={TOP + di * STRIDE}
width={CELL} width={CELL}
height={CELL} height={CELL}
rx={2} rx={3}
fill={LEVEL_FILL[toLevel(cell.count)]} style={{ fill: LEVEL_FILL[toLevel(cell.count)] }}
> >
<title> <title>
{cell.date} {cell.date}
@ -161,19 +173,58 @@ export function ForgejoHeatmap({
</div> </div>
{/* Legend */} {/* 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> <span>Less</span>
{LEVEL_FILL.map((fill, i) => ( {LEVEL_FILL.map((fill, i) => (
<div <div
key={i} 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> <span>More</span>
</div> </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> </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> </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() { export function ProviderNavbarStatus() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]); const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
@ -249,6 +255,13 @@ export function ProviderNavbarStatus() {
Record<string, ProviderUsageLiveRead | null> Record<string, ProviderUsageLiveRead | null>
>({}); >({});
const [isUsageLoading, setIsUsageLoading] = useState(false); 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(() => { const usageCredentials = useMemo(() => {
return credentials return credentials
@ -321,6 +334,7 @@ export function ProviderNavbarStatus() {
); );
if (!cancelled) { if (!cancelled) {
setUsageByCredentialId(Object.fromEntries(pairs)); setUsageByCredentialId(Object.fromEntries(pairs));
setLastFetchedAt(new Date());
setIsUsageLoading(false); setIsUsageLoading(false);
} }
}; };
@ -364,6 +378,14 @@ export function ProviderNavbarStatus() {
<ProviderInlineStatus item={item} /> <ProviderInlineStatus item={item} />
</div> </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> </div>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>

View File

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