feat: heatmap

This commit is contained in:
null 2026-05-20 03:54:58 -05:00
parent fbc79e6777
commit 6f789a4284
6 changed files with 327 additions and 2 deletions

View File

@ -16,7 +16,7 @@ from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import MetricsResponse
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
@ -255,6 +255,82 @@ async def get_forgejo_metrics(
)
@router.get(
"/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.",
)
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."""
from sqlalchemy import Date as SADate
from sqlalchemy import cast as sa_cast
if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since = utcnow() - timedelta(days=365)
repo_ids_result = (
await session.exec(
select(ForgejoRepository.id).where(
ForgejoRepository.organization_id == ctx.organization.id,
)
)
).all()
if not repo_ids_result:
return HeatmapResponse(days=[], max_count=0)
repo_ids = list(repo_ids_result)
counts: dict[str, int] = {}
# Issues created per day
created_rows = (
await session.exec(
select(
sa_cast(ForgejoIssue.forgejo_created_at, SADate).label("day"),
func.count().label("cnt"),
).where(
ForgejoIssue.repository_id.in_(repo_ids),
ForgejoIssue.is_pull_request.is_(False),
ForgejoIssue.forgejo_created_at.is_not(None),
ForgejoIssue.forgejo_created_at >= since,
).group_by(sa_cast(ForgejoIssue.forgejo_created_at, SADate))
)
).all()
for day, cnt in created_rows:
if day:
key = str(day)
counts[key] = counts.get(key, 0) + int(cnt)
# Issues closed per day
closed_rows = (
await session.exec(
select(
sa_cast(ForgejoIssue.forgejo_closed_at, SADate).label("day"),
func.count().label("cnt"),
).where(
ForgejoIssue.repository_id.in_(repo_ids),
ForgejoIssue.is_pull_request.is_(False),
ForgejoIssue.forgejo_closed_at.is_not(None),
ForgejoIssue.forgejo_closed_at >= since,
).group_by(sa_cast(ForgejoIssue.forgejo_closed_at, SADate))
)
).all()
for day, cnt in closed_rows:
if day:
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)
def _zeroed_metrics() -> MetricsResponse:
"""Return zeroed metrics for empty scopes."""
return MetricsResponse(

View File

@ -121,6 +121,20 @@ class ForgejoIssueMetrics(SQLModel):
sync_error_counts: dict[str, int]
class HeatmapDay(SQLModel):
"""Single day in the issue activity heatmap."""
date: str # "YYYY-MM-DD"
count: int
class HeatmapResponse(SQLModel):
"""Issue activity heatmap for the last 365 days."""
days: list[HeatmapDay]
max_count: int
class MetricsResponse(SQLModel):
"""Generic metrics response wrapper."""

View File

@ -42,8 +42,10 @@ import {
} from "@/api/generated/activity/activity";
import type { ActivityEventRead } from "@/api/generated/model";
import {
getForgejoHeatmap,
getForgejoMetrics,
getForgejoRepositories,
type ForgejoHeatmapDay,
type ForgejoIssueMetrics,
type ForgejoRepository,
} from "@/lib/api-forgejo";
@ -491,6 +493,22 @@ export default function DashboardPage() {
},
});
const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number } | null, Error>({
queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId],
enabled: Boolean(
isSignedIn &&
forgejoOrganizationId &&
!forgejoRepositoriesQuery.isLoading &&
!forgejoRepositoriesQuery.error,
),
refetchInterval: 60_000,
refetchOnMount: "always",
queryFn: () => {
if (!forgejoOrganizationId) return Promise.resolve(null);
return getForgejoHeatmap({ organization_id: forgejoOrganizationId });
},
});
const boards = useMemo(
() =>
boardsQuery.data?.status === 200
@ -951,6 +969,9 @@ export default function DashboardPage() {
repositories={forgejoRepositories}
isLoading={forgejoIssueMetricsLoading}
error={forgejoIssueMetricsError}
heatmapDays={forgejoHeatmapQuery.data?.days ?? []}
heatmapMaxCount={forgejoHeatmapQuery.data?.max_count ?? 0}
heatmapLoading={forgejoHeatmapQuery.isLoading}
/>
</div>

View File

@ -0,0 +1,173 @@
"use client";
import { useMemo } from "react";
import type { ForgejoHeatmapDay } from "@/lib/api-forgejo";
interface ForgejoHeatmapProps {
days: ForgejoHeatmapDay[];
maxCount: number;
isLoading?: boolean;
}
// Layout constants — match Forgejo's contribution graph
const CELL = 10;
const GAP = 3;
const STRIDE = CELL + GAP; // 13px per cell
const WEEKS = 53;
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)
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
];
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 {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function ForgejoHeatmap({
days,
maxCount: _maxCount,
isLoading = false,
}: ForgejoHeatmapProps) {
const { weeks, monthLabels } = useMemo(() => {
const data = new Map<string, number>(days.map((d) => [d.date, d.count]));
// Start on the Sunday that is ~52 weeks before today
const today = new Date();
today.setHours(0, 0, 0, 0);
const start = new Date(today);
start.setDate(start.getDate() - WEEKS * 7 + 1);
start.setDate(start.getDate() - start.getDay()); // rewind to Sunday
type Cell = { date: string; count: number; future: boolean };
const builtWeeks: Cell[][] = [];
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 };
}, [days]);
const svgW = LEFT + WEEKS * STRIDE;
const svgH = TOP + 7 * STRIDE - GAP;
if (isLoading) {
return (
<div
className="animate-pulse rounded"
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }}
/>
);
}
return (
<div>
<div style={{ overflowX: "auto" }}>
<svg
width={svgW}
height={svgH}
aria-label="Issue activity heatmap"
style={{ display: "block" }}
>
{/* Month labels */}
{monthLabels.map(({ weekIdx, label }) => (
<text
key={`m-${weekIdx}`}
x={LEFT + weekIdx * STRIDE}
y={11}
fontSize={10}
fill="currentColor"
className="text-muted"
>
{label}
</text>
))}
{/* Day-of-week labels: Mon=row1, Wed=row3, Fri=row5 */}
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
<text
key={label}
x={0}
y={TOP + (i * 2 + 1) * STRIDE - GAP}
fontSize={10}
fill="currentColor"
className="text-muted"
>
{label}
</text>
))}
{/* Cells */}
{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={2}
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="mt-2 flex items-center justify-end 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 }}
/>
))}
<span>More</span>
</div>
</div>
);
}

View File

@ -11,15 +11,19 @@ import {
RefreshCw,
} from "lucide-react";
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
import type { ForgejoHeatmapDay, ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
import { formatRelativeTimestamp } from "@/lib/formatters";
import { cn } from "@/lib/utils";
import { ForgejoHeatmap } from "@/components/git/ForgejoHeatmap";
type ForgejoIssueMetricCardsProps = {
metrics: ForgejoIssueMetrics | null;
repositories: ForgejoRepository[];
isLoading?: boolean;
error?: string | null;
heatmapDays?: ForgejoHeatmapDay[];
heatmapMaxCount?: number;
heatmapLoading?: boolean;
};
type MetricTone = "accent" | "success" | "warning" | "danger" | "muted";
@ -193,6 +197,9 @@ export function ForgejoIssueMetricCards({
repositories,
isLoading = false,
error,
heatmapDays = [],
heatmapMaxCount = 0,
heatmapLoading = false,
}: ForgejoIssueMetricCardsProps) {
const openIssues = metrics?.open_issues ?? 0;
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
@ -282,6 +289,17 @@ export function ForgejoIssueMetricCards({
issue metrics yet.
</div>
) : null}
<div className="mt-5 border-t border-[color:var(--border)] pt-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-muted">
Issue Activity Last 12 Months
</p>
<ForgejoHeatmap
days={heatmapDays}
maxCount={heatmapMaxCount}
isLoading={heatmapLoading}
/>
</div>
</section>
);
}

View File

@ -438,6 +438,29 @@ export interface ForgejoIssueMetrics {
sync_error_counts: Record<string, number>;
}
// Forgejo Heatmap API
export interface ForgejoHeatmapDay {
date: string; // "YYYY-MM-DD"
count: number;
}
export interface ForgejoHeatmapResponse {
days: ForgejoHeatmapDay[];
max_count: number;
}
export async function getForgejoHeatmap(params?: {
organization_id?: string;
}): Promise<ForgejoHeatmapResponse> {
const searchParams = new URLSearchParams();
if (params?.organization_id)
searchParams.set("organization_id", params.organization_id);
const qs = searchParams.toString();
return fetchJson<ForgejoHeatmapResponse>(
`/api/v1/forgejo/heatmap${qs ? `?${qs}` : ""}`,
);
}
// Forgejo Metrics API
export async function getForgejoMetrics(params?: {
organization_id?: string;