feat(forgejo-metrics): refactor line stats fetching to use commit stats endpoint and update caching logic
This commit is contained in:
parent
7802400970
commit
2d91325937
|
|
@ -42,32 +42,24 @@ _LINE_STATS_TTL_MISS = 30 # 30 s — retry cadence while Forgejo is still com
|
||||||
|
|
||||||
async def _bg_fetch_line_stats(
|
async def _bg_fetch_line_stats(
|
||||||
cache_key: str,
|
cache_key: str,
|
||||||
# Plain tuples so this task never touches a closed DB session
|
|
||||||
repos: list[tuple[str, str, str, str | None]], # (owner, repo, base_url, token)
|
repos: list[tuple[str, str, str, str | None]], # (owner, repo, base_url, token)
|
||||||
since_ts: float,
|
since_iso: str, # ISO-8601 date string for the commits?since= filter
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background task: fetch per-repo contributor stats and cache the totals."""
|
"""Background task: sum commit line stats across all tracked repos and cache."""
|
||||||
|
|
||||||
async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, int, bool]:
|
async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, int]:
|
||||||
try:
|
try:
|
||||||
async with ForgejoAPIClient(base_url=base_url, token=token) as client:
|
async with ForgejoAPIClient(base_url=base_url, token=token) as client:
|
||||||
contributors, has_data = await client.get_contributor_stats(owner, repo)
|
return await client.get_commit_line_stats_since(owner, repo, since_iso)
|
||||||
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:
|
except Exception:
|
||||||
return 0, 0, False
|
return 0, 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos])
|
results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos])
|
||||||
total_adds = sum(a for a, _, _ in results)
|
total_adds = sum(a for a, _ in results)
|
||||||
total_dels = sum(d for _, d, _ in results)
|
total_dels = sum(d for _, d in results)
|
||||||
has_data = bool(results) and all(ok for _, _, ok in results)
|
# Always mark has_data=True — the commits endpoint is reliable
|
||||||
_line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, has_data)
|
_line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, True)
|
||||||
finally:
|
finally:
|
||||||
_line_stats_fetching.discard(cache_key)
|
_line_stats_fetching.discard(cache_key)
|
||||||
|
|
||||||
|
|
@ -449,7 +441,7 @@ async def get_forgejo_heatmap(
|
||||||
|
|
||||||
# Line stats — served from cache; background task refreshes when stale.
|
# Line stats — served from cache; background task refreshes when stale.
|
||||||
# Extract plain values NOW while the DB session is still open.
|
# Extract plain values NOW while the DB session is still open.
|
||||||
since_ts = since.timestamp()
|
since_iso = since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
cache_key = str(ctx.organization.id)
|
cache_key = str(ctx.organization.id)
|
||||||
cached = _line_stats_cache.get(cache_key)
|
cached = _line_stats_cache.get(cache_key)
|
||||||
now = _time.monotonic()
|
now = _time.monotonic()
|
||||||
|
|
@ -471,7 +463,7 @@ async def get_forgejo_heatmap(
|
||||||
|
|
||||||
_line_stats_fetching.add(cache_key)
|
_line_stats_fetching.add(cache_key)
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_bg_fetch_line_stats(cache_key, repo_tuples, since_ts),
|
_bg_fetch_line_stats(cache_key, repo_tuples, since_iso),
|
||||||
name=f"line-stats-{cache_key}",
|
name=f"line-stats-{cache_key}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -368,28 +368,38 @@ class ForgejoAPIClient:
|
||||||
commits = data if isinstance(data, list) else data.get("commits") or data.get("data") or []
|
commits = data if isinstance(data, list) else data.get("commits") or data.get("data") or []
|
||||||
return commits[0] if commits else None
|
return commits[0] if commits else None
|
||||||
|
|
||||||
async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]:
|
async def get_commit_line_stats_since(
|
||||||
"""Fetch per-contributor weekly stats for a repository.
|
self, owner: str, repo: str, since_iso: str
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Sum additions and deletions across all commits since ``since_iso``.
|
||||||
|
|
||||||
Returns (contributors, has_data). Forgejo returns HTTP 202 while it
|
Uses ``GET /repos/{owner}/{repo}/commits?since=…&stat=true`` which
|
||||||
computes stats — that is not an error, it just means the caller should
|
returns per-commit ``stats`` objects and is available on all Forgejo/
|
||||||
retry later. has_data=False signals the 202 case; the application layer
|
Gitea versions. Returns ``(total_additions, total_deletions)``.
|
||||||
uses a short retry TTL so it probes again within ~30 s.
|
|
||||||
|
|
||||||
Each contributor dict has a ``weeks`` array with ``w`` (Unix timestamp),
|
|
||||||
``a`` (additions), and ``d`` (deletions).
|
|
||||||
"""
|
"""
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
response = await client.get(
|
total_adds = total_dels = 0
|
||||||
f"/api/v1/repos/{owner}/{repo}/stats/contributors"
|
page = 1
|
||||||
)
|
while True:
|
||||||
if response.status_code == 202:
|
response = await client.get(
|
||||||
return [], False # still computing — caller will retry shortly
|
f"/api/v1/repos/{owner}/{repo}/commits",
|
||||||
if response.status_code == 404:
|
params={"since": since_iso, "limit": 50, "page": page, "stat": "true"},
|
||||||
return [], True # repo exists but no commits; treat as data-present
|
)
|
||||||
response.raise_for_status()
|
if response.status_code in (404, 409):
|
||||||
data = response.json()
|
break # empty or non-existent repo
|
||||||
return (data if isinstance(data, list) else []), True
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
commits = data if isinstance(data, list) else []
|
||||||
|
if not commits:
|
||||||
|
break
|
||||||
|
for commit in commits:
|
||||||
|
s = commit.get("stats") or {}
|
||||||
|
total_adds += int(s.get("additions") or 0)
|
||||||
|
total_dels += int(s.get("deletions") or 0)
|
||||||
|
if len(commits) < 50:
|
||||||
|
break # last page
|
||||||
|
page += 1
|
||||||
|
return total_adds, total_dels
|
||||||
|
|
||||||
|
|
||||||
def get_forgejo_client(
|
def get_forgejo_client(
|
||||||
|
|
|
||||||
|
|
@ -13,36 +13,25 @@ interface ForgejoHeatmapProps {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CELL = 16;
|
// Chart layout
|
||||||
const GAP = 4;
|
const VW = 700; // viewBox width
|
||||||
const STRIDE = CELL + GAP;
|
const VH = 160; // viewBox height
|
||||||
const WEEKS = 27;
|
const PAD_L = 8;
|
||||||
const LEFT = 32;
|
const PAD_R = 8;
|
||||||
const TOP = 20;
|
const PAD_T = 12;
|
||||||
|
const PAD_B = 28; // room for month labels
|
||||||
|
const CW = VW - PAD_L - PAD_R; // chart width
|
||||||
|
const CH = VH - PAD_T - PAD_B; // chart height
|
||||||
|
const DAYS = 183;
|
||||||
|
|
||||||
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"];
|
||||||
|
|
||||||
// All fixed rgba — no CSS vars inside SVG fill so every browser renders them
|
const VIOLET = "rgba(139,92,246,1)";
|
||||||
const LEVEL_FILL = [
|
const GREEN = "rgba(52,211,153,1)";
|
||||||
"rgba(139,92,246,0.08)", // 0 empty — barely-there grid
|
const RED = "rgba(248,113,113,1)";
|
||||||
"rgba(139,92,246,0.30)", // 1
|
const WHITE70 = "rgba(255,255,255,0.70)";
|
||||||
"rgba(139,92,246,0.54)", // 2
|
const WHITE50 = "rgba(255,255,255,0.50)";
|
||||||
"rgba(139,92,246,0.77)", // 3
|
const WHITE15 = "rgba(255,255,255,0.15)";
|
||||||
"rgba(139,92,246,1.00)", // 4 full violet-500
|
|
||||||
];
|
|
||||||
|
|
||||||
const VIOLET = "rgba(139,92,246,1)";
|
|
||||||
const GREEN = "rgba(52,211,153,1)";
|
|
||||||
const RED = "rgba(248,113,113,1)";
|
|
||||||
const LABEL_CLR = "rgba(255,255,255,0.70)"; // white labels
|
|
||||||
|
|
||||||
function toLevel(count: number): number {
|
|
||||||
if (count === 0) return 0;
|
|
||||||
if (count <= 2) return 1;
|
|
||||||
if (count <= 5) return 2;
|
|
||||||
if (count <= 9) return 3;
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoDate(d: Date): string {
|
function isoDate(d: Date): string {
|
||||||
return [
|
return [
|
||||||
|
|
@ -59,132 +48,206 @@ function fmtLines(n: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtRelative(iso: string): string {
|
function fmtRelative(iso: string): string {
|
||||||
const diffMs = Date.now() - new Date(iso).getTime();
|
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
const s = Math.floor(diffMs / 1000);
|
if (s < 60) return "just now";
|
||||||
if (s < 60) return "just now";
|
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
if (m < 60) return `${m}m ago`;
|
if (m < 60) return `${m}m ago`;
|
||||||
const h = Math.floor(m / 60);
|
const h = Math.floor(m / 60);
|
||||||
if (h < 24) return `${h}h ago`;
|
if (h < 24) return `${h}h ago`;
|
||||||
return `${Math.floor(h / 24)}d ago`;
|
return `${Math.floor(h / 24)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ForgejoHeatmap({
|
export function ForgejoHeatmap({
|
||||||
days,
|
days,
|
||||||
maxCount: _maxCount,
|
maxCount,
|
||||||
totalAdditions = 0,
|
totalAdditions = 0,
|
||||||
totalDeletions = 0,
|
totalDeletions = 0,
|
||||||
hasLineStats = false,
|
hasLineStats = false,
|
||||||
lastPush = null,
|
lastPush = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ForgejoHeatmapProps) {
|
}: ForgejoHeatmapProps) {
|
||||||
const { weeks, monthLabels } = useMemo(() => {
|
const { points, monthLabels, gridLines, peak } = useMemo(() => {
|
||||||
const data = new Map(days.map((d) => [d.date, d.count]));
|
const lookup = new Map(days.map((d) => [d.date, d.count]));
|
||||||
|
|
||||||
|
// Build a dense 183-day array ending today
|
||||||
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 series: { date: string; count: number }[] = [];
|
||||||
start.setDate(start.getDate() - start.getDay());
|
for (let i = DAYS - 1; i >= 0; i--) {
|
||||||
start.setDate(start.getDate() - (WEEKS - 1) * 7);
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
type Cell = { date: string; count: number; future: boolean };
|
const key = isoDate(d);
|
||||||
const builtWeeks: Cell[][] = [];
|
series.push({ date: key, count: lookup.get(key) ?? 0 });
|
||||||
const monthLabelList: { weekIdx: number; label: string }[] = [];
|
|
||||||
let lastMonth = -1;
|
|
||||||
const cur = new Date(start);
|
|
||||||
|
|
||||||
for (let w = 0; w < WEEKS; w++) {
|
|
||||||
const week: Cell[] = [];
|
|
||||||
for (let d = 0; d < 7; d++) {
|
|
||||||
const dateStr = isoDate(cur);
|
|
||||||
const month = cur.getMonth();
|
|
||||||
if (d === 0 && month !== lastMonth) {
|
|
||||||
monthLabelList.push({ weekIdx: w, label: MONTHS[month] });
|
|
||||||
lastMonth = month;
|
|
||||||
}
|
|
||||||
week.push({ date: dateStr, count: data.get(dateStr) ?? 0, future: cur > today });
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
builtWeeks.push(week);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { weeks: builtWeeks, monthLabels: monthLabelList };
|
const localMax = Math.max(...series.map((p) => p.count), 1);
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
const svgW = LEFT + WEEKS * STRIDE;
|
// Map to SVG coords
|
||||||
const svgH = TOP + 7 * STRIDE - GAP;
|
const pts = series.map((p, i) => ({
|
||||||
|
x: PAD_L + (i / (DAYS - 1)) * CW,
|
||||||
|
y: PAD_T + CH - (p.count / localMax) * CH,
|
||||||
|
date: p.date,
|
||||||
|
count: p.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Month label positions
|
||||||
|
const labels: { x: number; label: string }[] = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
series.forEach((p, i) => {
|
||||||
|
const m = new Date(p.date).getMonth();
|
||||||
|
if (m !== lastMonth) {
|
||||||
|
labels.push({ x: PAD_L + (i / (DAYS - 1)) * CW, label: MONTHS[m] });
|
||||||
|
lastMonth = m;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizontal grid lines at 25 / 50 / 75 % of chart height
|
||||||
|
const grid = [0.25, 0.5, 0.75].map((f) => PAD_T + CH * (1 - f));
|
||||||
|
|
||||||
|
// Peak point (highest count)
|
||||||
|
const peak = pts.reduce((best, p) => (p.count > best.count ? p : best), pts[0]);
|
||||||
|
|
||||||
|
return { points: pts, monthLabels: labels, gridLines: grid, peak };
|
||||||
|
}, [days, maxCount]);
|
||||||
|
|
||||||
|
// Build SVG path strings
|
||||||
|
const lineD = useMemo(() => {
|
||||||
|
if (!points.length) return "";
|
||||||
|
return points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const areaD = useMemo(() => {
|
||||||
|
if (!points.length) return "";
|
||||||
|
const bottom = PAD_T + CH;
|
||||||
|
return (
|
||||||
|
`M${PAD_L},${bottom} ` +
|
||||||
|
points.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") +
|
||||||
|
` L${(PAD_L + CW).toFixed(1)},${bottom} Z`
|
||||||
|
);
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="animate-pulse rounded" style={{ width: svgW, height: svgH, background: "rgba(139,92,246,0.08)" }} />
|
<div className="w-full animate-pulse rounded-lg" style={{ height: VH, background: "rgba(139,92,246,0.08)" }} />
|
||||||
<div className="mx-auto h-8 w-64 animate-pulse rounded-full" style={{ background: "rgba(139,92,246,0.08)" }} />
|
<div className="mx-auto h-6 w-64 animate-pulse rounded" style={{ background: "rgba(139,92,246,0.08)" }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{/* ── Heatmap ─────────────────────────────────────────────── */}
|
{/* ── Area chart ──────────────────────────────────────────── */}
|
||||||
<div className="flex justify-center" style={{ overflowX: "auto" }}>
|
<svg
|
||||||
<svg width={svgW} height={svgH} aria-label="Issue activity heatmap" style={{ display: "block" }}>
|
viewBox={`0 0 ${VW} ${VH}`}
|
||||||
|
width="100%"
|
||||||
|
aria-label="Git activity graph"
|
||||||
|
style={{ display: "block", overflow: "visible" }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="git-area-gradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(139,92,246,0.45)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(139,92,246,0.02)" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="git-chart-clip">
|
||||||
|
<rect x={PAD_L} y={PAD_T} width={CW} height={CH} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
{/* Month labels */}
|
{/* Grid lines */}
|
||||||
{monthLabels.map(({ weekIdx, label }) => (
|
{gridLines.map((y, i) => (
|
||||||
<text key={`m-${weekIdx}`} x={LEFT + weekIdx * STRIDE} y={11} fontSize={10} style={{ fill: LABEL_CLR }}>
|
<line
|
||||||
{label}
|
key={i}
|
||||||
</text>
|
x1={PAD_L} y1={y} x2={PAD_L + CW} y2={y}
|
||||||
))}
|
style={{ stroke: WHITE15, strokeWidth: 1 }}
|
||||||
|
strokeDasharray="3 4"
|
||||||
{/* Day labels */}
|
/>
|
||||||
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
|
|
||||||
<text key={label} x={0} y={TOP + (i * 2 + 1) * STRIDE + CELL - 2} fontSize={10} style={{ fill: LABEL_CLR }}>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Cells — style.fill so rgba resolves correctly in SVG */}
|
|
||||||
{weeks.map((week, wi) =>
|
|
||||||
week.map((cell, di) =>
|
|
||||||
cell.future ? null : (
|
|
||||||
<rect
|
|
||||||
key={cell.date}
|
|
||||||
x={LEFT + wi * STRIDE}
|
|
||||||
y={TOP + di * STRIDE}
|
|
||||||
width={CELL}
|
|
||||||
height={CELL}
|
|
||||||
rx={3}
|
|
||||||
style={{ fill: LEVEL_FILL[toLevel(cell.count)] }}
|
|
||||||
>
|
|
||||||
<title>
|
|
||||||
{cell.date}
|
|
||||||
{cell.count > 0 ? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}` : ": no activity"}
|
|
||||||
</title>
|
|
||||||
</rect>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Legend ──────────────────────────────────────────────── */}
|
|
||||||
<div className="flex items-center justify-center gap-1.5 text-xs" style={{ color: LABEL_CLR }}>
|
|
||||||
<span>Less</span>
|
|
||||||
{LEVEL_FILL.map((fill, i) => (
|
|
||||||
<div key={i} style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }} />
|
|
||||||
))}
|
))}
|
||||||
<span>More</span>
|
|
||||||
</div>
|
{/* Filled area */}
|
||||||
|
<path
|
||||||
|
d={areaD}
|
||||||
|
style={{ fill: "url(#git-area-gradient)" }}
|
||||||
|
clipPath="url(#git-chart-clip)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
<path
|
||||||
|
d={lineD}
|
||||||
|
style={{ fill: "none", stroke: VIOLET, strokeWidth: 1.8, strokeLinejoin: "round", strokeLinecap: "round" }}
|
||||||
|
clipPath="url(#git-chart-clip)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Peak dot + label */}
|
||||||
|
{peak && peak.count > 0 && (
|
||||||
|
<>
|
||||||
|
<circle cx={peak.x} cy={peak.y} r={4} style={{ fill: VIOLET }} />
|
||||||
|
<circle cx={peak.x} cy={peak.y} r={7} style={{ fill: "rgba(139,92,246,0.20)" }} />
|
||||||
|
<text
|
||||||
|
x={Math.min(Math.max(peak.x, PAD_L + 16), PAD_L + CW - 16)}
|
||||||
|
y={peak.y - 12}
|
||||||
|
fontSize={10}
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ fill: WHITE70 }}
|
||||||
|
>
|
||||||
|
{peak.count}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Today dot */}
|
||||||
|
{points.length > 0 && (
|
||||||
|
<circle
|
||||||
|
cx={points[points.length - 1].x}
|
||||||
|
cy={points[points.length - 1].y}
|
||||||
|
r={3}
|
||||||
|
style={{ fill: VIOLET }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* X-axis baseline */}
|
||||||
|
<line
|
||||||
|
x1={PAD_L} y1={PAD_T + CH} x2={PAD_L + CW} y2={PAD_T + CH}
|
||||||
|
style={{ stroke: WHITE15, strokeWidth: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Month labels */}
|
||||||
|
{monthLabels.map(({ x, label }, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={x}
|
||||||
|
y={VH - 8}
|
||||||
|
fontSize={10}
|
||||||
|
style={{ fill: WHITE70 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Invisible hit targets for per-day tooltip */}
|
||||||
|
{points.map((p) => (
|
||||||
|
<rect
|
||||||
|
key={p.date}
|
||||||
|
x={p.x - CW / (DAYS * 2)}
|
||||||
|
y={PAD_T}
|
||||||
|
width={CW / DAYS}
|
||||||
|
height={CH}
|
||||||
|
style={{ fill: "transparent", cursor: "crosshair" }}
|
||||||
|
>
|
||||||
|
<title>{p.date}{p.count > 0 ? `: ${p.count} event${p.count !== 1 ? "s" : ""}` : ": no activity"}</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
{/* ── Contributions summary ────────────────────────────────── */}
|
{/* ── Contributions summary ────────────────────────────────── */}
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
<span className="text-2xl font-bold tabular-nums" style={{ color: VIOLET }}>
|
<span className="text-2xl font-bold tabular-nums" style={{ color: VIOLET }}>
|
||||||
{totalEvents.toLocaleString()}
|
{totalEvents.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-1.5 text-sm" style={{ color: "rgba(255,255,255,0.70)" }}>
|
<span className="ml-1.5 text-sm" style={{ color: WHITE70 }}>
|
||||||
contributions across all tracked repositories in the last 6 months
|
contributions across all tracked repositories in the last 6 months
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -195,7 +258,6 @@ export function ForgejoHeatmap({
|
||||||
className="mx-auto flex max-w-lg items-start gap-3 rounded-lg px-4 py-2.5"
|
className="mx-auto flex max-w-lg items-start gap-3 rounded-lg px-4 py-2.5"
|
||||||
style={{ background: "rgba(139,92,246,0.08)", border: "1px solid rgba(139,92,246,0.18)" }}
|
style={{ background: "rgba(139,92,246,0.08)", border: "1px solid rgba(139,92,246,0.18)" }}
|
||||||
>
|
>
|
||||||
{/* commit hash pill */}
|
|
||||||
<span
|
<span
|
||||||
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold"
|
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold"
|
||||||
style={{ background: "rgba(139,92,246,0.20)", color: VIOLET }}
|
style={{ background: "rgba(139,92,246,0.20)", color: VIOLET }}
|
||||||
|
|
@ -206,13 +268,10 @@ export function ForgejoHeatmap({
|
||||||
<p className="truncate text-sm font-medium" style={{ color: "rgba(255,255,255,0.90)" }}>
|
<p className="truncate text-sm font-medium" style={{ color: "rgba(255,255,255,0.90)" }}>
|
||||||
{lastPush.message}
|
{lastPush.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs" style={{ color: "rgba(255,255,255,0.50)" }}>
|
<p className="mt-0.5 text-xs" style={{ color: WHITE50 }}>
|
||||||
{lastPush.author} pushed to{" "}
|
{lastPush.author} pushed to{" "}
|
||||||
<span style={{ color: "rgba(139,92,246,0.90)" }}>{lastPush.branch}</span>
|
<span style={{ color: "rgba(139,92,246,0.90)" }}>{lastPush.branch}</span>
|
||||||
{" · "}
|
{" · "}{lastPush.repo}{" · "}{fmtRelative(lastPush.pushed_at)}
|
||||||
{lastPush.repo}
|
|
||||||
{" · "}
|
|
||||||
{fmtRelative(lastPush.pushed_at)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,22 +284,18 @@ export function ForgejoHeatmap({
|
||||||
<span className="text-2xl font-bold tabular-nums" style={{ color: GREEN }}>
|
<span className="text-2xl font-bold tabular-nums" style={{ color: GREEN }}>
|
||||||
+{fmtLines(totalAdditions)}
|
+{fmtLines(totalAdditions)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium" style={{ color: "rgba(52,211,153,0.70)" }}>
|
<span className="text-xs font-medium" style={{ color: "rgba(52,211,153,0.70)" }}>lines added</span>
|
||||||
lines added
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: 1, height: 40, background: "rgba(139,92,246,0.20)" }} />
|
<div style={{ width: 1, height: 40, background: "rgba(139,92,246,0.20)" }} />
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span className="text-2xl font-bold tabular-nums" style={{ color: RED }}>
|
<span className="text-2xl font-bold tabular-nums" style={{ color: RED }}>
|
||||||
-{fmtLines(totalDeletions)}
|
-{fmtLines(totalDeletions)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium" style={{ color: "rgba(248,113,113,0.70)" }}>
|
<span className="text-xs font-medium" style={{ color: "rgba(248,113,113,0.70)" }}>lines removed</span>
|
||||||
lines removed
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-xs" style={{ color: LABEL_CLR, opacity: 0.6 }}>
|
<p className="text-center text-xs" style={{ color: WHITE70, opacity: 0.6 }}>
|
||||||
Line stats syncing with Forgejo — will appear on next refresh
|
Line stats syncing with Forgejo — will appear on next refresh
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue