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.board_repository_links import BoardRepositoryLink
|
||||||
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 MetricsResponse
|
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
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:
|
def _zeroed_metrics() -> MetricsResponse:
|
||||||
"""Return zeroed metrics for empty scopes."""
|
"""Return zeroed metrics for empty scopes."""
|
||||||
return MetricsResponse(
|
return MetricsResponse(
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,20 @@ class ForgejoIssueMetrics(SQLModel):
|
||||||
sync_error_counts: dict[str, int]
|
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):
|
class MetricsResponse(SQLModel):
|
||||||
"""Generic metrics response wrapper."""
|
"""Generic metrics response wrapper."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,10 @@ import {
|
||||||
} from "@/api/generated/activity/activity";
|
} from "@/api/generated/activity/activity";
|
||||||
import type { ActivityEventRead } from "@/api/generated/model";
|
import type { ActivityEventRead } from "@/api/generated/model";
|
||||||
import {
|
import {
|
||||||
|
getForgejoHeatmap,
|
||||||
getForgejoMetrics,
|
getForgejoMetrics,
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
|
type ForgejoHeatmapDay,
|
||||||
type ForgejoIssueMetrics,
|
type ForgejoIssueMetrics,
|
||||||
type ForgejoRepository,
|
type ForgejoRepository,
|
||||||
} from "@/lib/api-forgejo";
|
} 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(
|
const boards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
boardsQuery.data?.status === 200
|
boardsQuery.data?.status === 200
|
||||||
|
|
@ -951,6 +969,9 @@ export default function DashboardPage() {
|
||||||
repositories={forgejoRepositories}
|
repositories={forgejoRepositories}
|
||||||
isLoading={forgejoIssueMetricsLoading}
|
isLoading={forgejoIssueMetricsLoading}
|
||||||
error={forgejoIssueMetricsError}
|
error={forgejoIssueMetricsError}
|
||||||
|
heatmapDays={forgejoHeatmapQuery.data?.days ?? []}
|
||||||
|
heatmapMaxCount={forgejoHeatmapQuery.data?.max_count ?? 0}
|
||||||
|
heatmapLoading={forgejoHeatmapQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} 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 { formatRelativeTimestamp } from "@/lib/formatters";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ForgejoHeatmap } from "@/components/git/ForgejoHeatmap";
|
||||||
|
|
||||||
type ForgejoIssueMetricCardsProps = {
|
type ForgejoIssueMetricCardsProps = {
|
||||||
metrics: ForgejoIssueMetrics | null;
|
metrics: ForgejoIssueMetrics | null;
|
||||||
repositories: ForgejoRepository[];
|
repositories: ForgejoRepository[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
heatmapDays?: ForgejoHeatmapDay[];
|
||||||
|
heatmapMaxCount?: number;
|
||||||
|
heatmapLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MetricTone = "accent" | "success" | "warning" | "danger" | "muted";
|
type MetricTone = "accent" | "success" | "warning" | "danger" | "muted";
|
||||||
|
|
@ -193,6 +197,9 @@ export function ForgejoIssueMetricCards({
|
||||||
repositories,
|
repositories,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error,
|
error,
|
||||||
|
heatmapDays = [],
|
||||||
|
heatmapMaxCount = 0,
|
||||||
|
heatmapLoading = false,
|
||||||
}: ForgejoIssueMetricCardsProps) {
|
}: ForgejoIssueMetricCardsProps) {
|
||||||
const openIssues = metrics?.open_issues ?? 0;
|
const openIssues = metrics?.open_issues ?? 0;
|
||||||
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
||||||
|
|
@ -282,6 +289,17 @@ export function ForgejoIssueMetricCards({
|
||||||
issue metrics yet.
|
issue metrics yet.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,29 @@ export interface ForgejoIssueMetrics {
|
||||||
sync_error_counts: Record<string, number>;
|
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
|
// Forgejo Metrics API
|
||||||
export async function getForgejoMetrics(params?: {
|
export async function getForgejoMetrics(params?: {
|
||||||
organization_id?: string;
|
organization_id?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue