feat(metrics): update heatmap metrics to reflect data for the last year

This commit is contained in:
null 2026-05-22 17:54:42 -05:00
parent b01c11fb05
commit 502c44d560
3 changed files with 41 additions and 24 deletions

View File

@ -22,7 +22,7 @@ from app.models.forgejo_connections import ForgejoConnection
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 HeatmapDay, HeatmapResponse, LastPushRead, MetricsResponse from app.schemas.metrics import HeatmapDay, HeatmapResponse, LastPushRead, MetricsResponse
from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client from app.services.forgejo_client import ForgejoAPIClient
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@ -371,14 +371,14 @@ async def get_forgejo_metrics(
"/heatmap", "/heatmap",
response_model=HeatmapResponse, response_model=HeatmapResponse,
summary="Forgejo issue activity heatmap", summary="Forgejo issue activity heatmap",
description="Daily issue open+close event counts for the last 6 months, scoped to the caller's organisation.", description="Daily issue open+close event counts for the last year, scoped to the caller's organisation.",
) )
async def get_forgejo_heatmap( async def get_forgejo_heatmap(
organization_id: UUID | None = Query(None, description="Filter by organisation ID"), organization_id: UUID | None = Query(None, description="Filter by organisation ID"),
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> HeatmapResponse: ) -> HeatmapResponse:
"""Return per-day issue event counts and total line contributions for the last 6 months.""" """Return per-day issue event counts and total line contributions for the last year."""
if organization_id and organization_id != ctx.organization.id: if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)

View File

@ -140,7 +140,7 @@ class HeatmapDay(SQLModel):
class HeatmapResponse(SQLModel): class HeatmapResponse(SQLModel):
"""Issue activity heatmap for the last 6 months.""" """Issue activity heatmap for the last year."""
days: list[HeatmapDay] days: list[HeatmapDay]
max_count: int max_count: int

View File

@ -28,17 +28,23 @@ const LCH = LVH - LP_T - LP_B;
const HCELL = 11; const HCELL = 11;
const HGAP = 3; const HGAP = 3;
const HSTRIDE = HCELL + HGAP; const HSTRIDE = HCELL + HGAP;
const HWEEKS = 52;
const HLEFT = 30; const HLEFT = 30;
const HTOP = 18; const HTOP = 18;
const HVW = HLEFT + HWEEKS * HSTRIDE; const HMIN_VW = 420;
const HVH = HTOP + 7 * HSTRIDE - HGAP + 22; // +22 for legend const HVH = LVH;
// ── Types & data ─────────────────────────────────────────────────────────── // ── Types & data ───────────────────────────────────────────────────────────
type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y"; type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y";
const RANGE_DAYS: Record<RangeKey, number> = { const RANGE_DAYS: Record<RangeKey, number> = {
"7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365, "7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365,
}; };
const RANGE_SUMMARY: Record<RangeKey, string> = {
"7d": "7 days",
"30d": "30 days",
"3m": "3 months",
"6m": "6 months",
"1y": "1 year",
};
const RANGE_LABELS: RangeKey[] = ["7d", "30d", "3m", "6m", "1y"]; const RANGE_LABELS: RangeKey[] = ["7d", "30d", "3m", "6m", "1y"];
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"];
@ -185,7 +191,7 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
</div> </div>
{/* SVG */} {/* SVG */}
<svg viewBox={`0 0 ${LVW} ${LVH}`} width="100%" style={{ display:"block", overflow:"visible" }} <svg viewBox={`0 0 ${LVW} ${LVH}`} width="100%" style={{ display:"block", height:LVH, overflow:"visible" }}
aria-label={`Git activity last ${range}`}> aria-label={`Git activity last ${range}`}>
<defs> <defs>
<linearGradient id="la-fill" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="la-fill" x1="0" y1="0" x2="0" y2="1">
@ -249,41 +255,52 @@ function HeatmapGrid({ days, range, onRangeChange }: {
range: RangeKey; range: RangeKey;
onRangeChange: (r: RangeKey) => void; onRangeChange: (r: RangeKey) => void;
}) { }) {
const { weeks, monthLabels, totalEvents } = useMemo(() => { const { weeks, monthLabels, totalEvents, viewWidth } = useMemo(() => {
const lookup = new Map(days.map(d => [d.date, d.count])); const lookup = new Map(days.map(d => [d.date, d.count]));
const today = new Date(); today.setHours(0,0,0,0); const today = new Date(); today.setHours(0,0,0,0);
const start = new Date(today); const numDays = RANGE_DAYS[range];
start.setDate(start.getDate() - start.getDay()); const rangeStart = new Date(today);
start.setDate(start.getDate() - (HWEEKS-1)*7); rangeStart.setDate(rangeStart.getDate() - (numDays - 1));
const calendarStart = new Date(rangeStart);
calendarStart.setDate(calendarStart.getDate() - calendarStart.getDay());
const calendarEnd = new Date(today);
calendarEnd.setDate(calendarEnd.getDate() + (6 - calendarEnd.getDay()));
const weekCount = Math.max(
1,
Math.ceil(((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7),
);
type Cell = { date:string; count:number; future:boolean }; type Cell = { date:string; count:number; outsideRange:boolean };
const builtWeeks: Cell[][] = []; const builtWeeks: Cell[][] = [];
const monthLabelList: {weekIdx:number; label:string}[] = []; const monthLabelList: {weekIdx:number; label:string}[] = [];
let lastMonth = -1; let lastMonth = -1;
const cur = new Date(start); const cur = new Date(calendarStart);
for (let w = 0; w < HWEEKS; w++) { for (let w = 0; w < weekCount; w++) {
const week: Cell[] = []; const week: Cell[] = [];
for (let d = 0; d < 7; d++) { for (let d = 0; d < 7; d++) {
const dateStr = isoDate(cur); const dateStr = isoDate(cur);
const month = cur.getMonth(); const month = cur.getMonth();
if (d === 0 && month !== lastMonth) { if (cur >= rangeStart && cur <= today && month !== lastMonth) {
monthLabelList.push({ weekIdx: w, label: MONTHS[month] }); monthLabelList.push({ weekIdx: w, label: MONTHS[month] });
lastMonth = month; lastMonth = month;
} }
week.push({ date: dateStr, count: lookup.get(dateStr) ?? 0, future: cur > today }); week.push({
date: dateStr,
count: lookup.get(dateStr) ?? 0,
outsideRange: cur < rangeStart || cur > today,
});
cur.setDate(cur.getDate()+1); cur.setDate(cur.getDate()+1);
} }
builtWeeks.push(week); builtWeeks.push(week);
} }
// contributions count for selected range // contributions count for selected range
const numDays = RANGE_DAYS[range]; const cutoffStr = isoDate(rangeStart);
const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - numDays);
const cutoffStr = isoDate(cutoff);
const totalEvents = days.filter(d => d.date >= cutoffStr).reduce((s,d) => s+d.count, 0); const totalEvents = days.filter(d => d.date >= cutoffStr).reduce((s,d) => s+d.count, 0);
const viewWidth = Math.max(HMIN_VW, HLEFT + weekCount * HSTRIDE);
return { weeks: builtWeeks, monthLabels: monthLabelList, totalEvents }; return { weeks: builtWeeks, monthLabels: monthLabelList, totalEvents, viewWidth };
}, [days, range]); }, [days, range]);
return ( return (
@ -308,7 +325,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
</div> </div>
{/* SVG */} {/* SVG */}
<svg viewBox={`0 0 ${HVW} ${HVH}`} width="100%" style={{ display:"block" }} aria-label="Activity heatmap"> <svg viewBox={`0 0 ${viewWidth} ${HVH}`} width="100%" style={{ display:"block", height:LVH }} aria-label={`Activity heatmap last ${range}`}>
{monthLabels.map(({weekIdx, label}) => ( {monthLabels.map(({weekIdx, label}) => (
<text key={`m-${weekIdx}`} x={HLEFT+weekIdx*HSTRIDE} y={11} fontSize={10} style={{fill:W70}}>{label}</text> <text key={`m-${weekIdx}`} x={HLEFT+weekIdx*HSTRIDE} y={11} fontSize={10} style={{fill:W70}}>{label}</text>
))} ))}
@ -316,7 +333,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
<text key={label} x={0} y={HTOP+(i*2+1)*HSTRIDE+HCELL-2} fontSize={10} style={{fill:W70}}>{label}</text> <text key={label} x={0} y={HTOP+(i*2+1)*HSTRIDE+HCELL-2} fontSize={10} style={{fill:W70}}>{label}</text>
))} ))}
{weeks.map((week, wi) => {weeks.map((week, wi) =>
week.map((cell, di) => cell.future ? null : ( week.map((cell, di) => cell.outsideRange ? null : (
<rect key={cell.date} <rect key={cell.date}
x={HLEFT+wi*HSTRIDE} y={HTOP+di*HSTRIDE} x={HLEFT+wi*HSTRIDE} y={HTOP+di*HSTRIDE}
width={HCELL} height={HCELL} rx={2} width={HCELL} height={HCELL} rx={2}
@ -338,7 +355,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
{totalEvents.toLocaleString()} {totalEvents.toLocaleString()}
</span> </span>
<span className="ml-1.5 text-sm" style={{color:W70}}> <span className="ml-1.5 text-sm" style={{color:W70}}>
contributions across all tracked repositories in the last {range} contributions across all tracked repositories in the last {RANGE_SUMMARY[range]}
</span> </span>
</p> </p>