feat: heatmap
This commit is contained in:
parent
fbc79e6777
commit
6f789a4284
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue