feat(metrics): update heatmap metrics to reflect data for the last year
This commit is contained in:
parent
b01c11fb05
commit
502c44d560
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue