diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index 6a90d57..d862633 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -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( diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py index 72d990b..000d674 100644 --- a/backend/app/schemas/metrics.py +++ b/backend/app/schemas/metrics.py @@ -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.""" diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 3bed933..91134c6 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -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} /> diff --git a/frontend/src/components/git/ForgejoHeatmap.tsx b/frontend/src/components/git/ForgejoHeatmap.tsx new file mode 100644 index 0000000..fa2a4a8 --- /dev/null +++ b/frontend/src/components/git/ForgejoHeatmap.tsx @@ -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 0–4) +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(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 ( +
+ ); + } + + return ( +
+
+ + {/* Month labels */} + {monthLabels.map(({ weekIdx, label }) => ( + + {label} + + ))} + + {/* Day-of-week labels: Mon=row1, Wed=row3, Fri=row5 */} + {(["Mon", "Wed", "Fri"] as const).map((label, i) => ( + + {label} + + ))} + + {/* Cells */} + {weeks.map((week, wi) => + week.map((cell, di) => + cell.future ? null : ( + + + {cell.date} + {cell.count > 0 + ? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}` + : ": no activity"} + + + ), + ), + )} + +
+ + {/* Legend */} +
+ Less + {LEVEL_FILL.map((fill, i) => ( +
+ ))} + More +
+
+ ); +} diff --git a/frontend/src/components/git/ForgejoIssueMetricCards.tsx b/frontend/src/components/git/ForgejoIssueMetricCards.tsx index 0f0d4e2..a8d0e2e 100644 --- a/frontend/src/components/git/ForgejoIssueMetricCards.tsx +++ b/frontend/src/components/git/ForgejoIssueMetricCards.tsx @@ -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.
) : null} + +
+

+ Issue Activity — Last 12 Months +

+ +
); } diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts index 48be17c..abb9395 100644 --- a/frontend/src/lib/api-forgejo.ts +++ b/frontend/src/lib/api-forgejo.ts @@ -438,6 +438,29 @@ export interface ForgejoIssueMetrics { sync_error_counts: Record; } +// 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 { + const searchParams = new URLSearchParams(); + if (params?.organization_id) + searchParams.set("organization_id", params.organization_id); + const qs = searchParams.toString(); + return fetchJson( + `/api/v1/forgejo/heatmap${qs ? `?${qs}` : ""}`, + ); +} + // Forgejo Metrics API export async function getForgejoMetrics(params?: { organization_id?: string;