feat(forgejo-metrics): enhance heatmap metrics to include line contributions and adjust time frame to last 6 months
This commit is contained in:
parent
99965330b5
commit
bbfde53fe9
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 0–4)
|
// 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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?: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue