feat(dashboard): simplify sidebar navigation by removing redundant header
This commit is contained in:
parent
f0f53bcc73
commit
fded1d16da
|
|
@ -382,7 +382,7 @@ async def get_forgejo_heatmap(
|
||||||
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)
|
||||||
|
|
||||||
since = utcnow() - timedelta(days=183)
|
since = utcnow() - timedelta(days=365)
|
||||||
|
|
||||||
# Fetch repos with their connections in one query
|
# Fetch repos with their connections in one query
|
||||||
repos_with_conns = (
|
repos_with_conns = (
|
||||||
|
|
|
||||||
|
|
@ -1243,12 +1243,6 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-strong">
|
|
||||||
Git Activity
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<ForgejoHeatmap
|
<ForgejoHeatmap
|
||||||
days={forgejoHeatmapQuery.data?.days ?? []}
|
days={forgejoHeatmapQuery.data?.days ?? []}
|
||||||
maxCount={forgejoHeatmapQuery.data?.max_count ?? 0}
|
maxCount={forgejoHeatmapQuery.data?.max_count ?? 0}
|
||||||
|
|
@ -1258,7 +1252,6 @@ export default function DashboardPage() {
|
||||||
lastPush={forgejoLastPushQuery.data ?? null}
|
lastPush={forgejoLastPushQuery.data ?? null}
|
||||||
isLoading={forgejoHeatmapQuery.isLoading}
|
isLoading={forgejoHeatmapQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Activity, LayoutGrid } from "lucide-react";
|
||||||
import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
|
import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
|
||||||
|
|
||||||
interface ForgejoHeatmapProps {
|
interface ForgejoHeatmapProps {
|
||||||
|
|
@ -13,126 +14,323 @@ interface ForgejoHeatmapProps {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart layout
|
// ── Line chart layout ──────────────────────────────────────────────────────
|
||||||
const VW = 700; // viewBox width
|
const LVW = 580;
|
||||||
const VH = 160; // viewBox height
|
const LVH = 160;
|
||||||
const PAD_L = 8;
|
const LP_L = 34; // Y-axis labels
|
||||||
const PAD_R = 8;
|
const LP_R = 8;
|
||||||
const PAD_T = 12;
|
const LP_T = 10;
|
||||||
const PAD_B = 28; // room for month labels
|
const LP_B = 24;
|
||||||
const CW = VW - PAD_L - PAD_R; // chart width
|
const LCW = LVW - LP_L - LP_R;
|
||||||
const CH = VH - PAD_T - PAD_B; // chart height
|
const LCH = LVH - LP_T - LP_B;
|
||||||
const DAYS = 183;
|
|
||||||
|
|
||||||
|
// ── Heatmap layout ─────────────────────────────────────────────────────────
|
||||||
|
const HCELL = 11;
|
||||||
|
const HGAP = 3;
|
||||||
|
const HSTRIDE = HCELL + HGAP;
|
||||||
|
const HWEEKS = 52;
|
||||||
|
const HLEFT = 30;
|
||||||
|
const HTOP = 18;
|
||||||
|
const HVW = HLEFT + HWEEKS * HSTRIDE;
|
||||||
|
const HVH = HTOP + 7 * HSTRIDE - HGAP + 22; // +22 for legend
|
||||||
|
|
||||||
|
// ── Types & data ───────────────────────────────────────────────────────────
|
||||||
|
type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y";
|
||||||
|
const RANGE_DAYS: Record<RangeKey, number> = {
|
||||||
|
"7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365,
|
||||||
|
};
|
||||||
|
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"];
|
||||||
|
|
||||||
|
// ── Colors ─────────────────────────────────────────────────────────────────
|
||||||
const VIOLET = "rgba(139,92,246,1)";
|
const VIOLET = "rgba(139,92,246,1)";
|
||||||
const GREEN = "rgba(52,211,153,1)";
|
const GREEN = "rgba(52,211,153,1)";
|
||||||
const RED = "rgba(248,113,113,1)";
|
const RED = "rgba(248,113,113,1)";
|
||||||
const WHITE70 = "rgba(255,255,255,0.70)";
|
const W70 = "rgba(255,255,255,0.70)";
|
||||||
const WHITE50 = "rgba(255,255,255,0.50)";
|
const W40 = "rgba(255,255,255,0.40)";
|
||||||
const WHITE15 = "rgba(255,255,255,0.15)";
|
const W12 = "rgba(255,255,255,0.12)";
|
||||||
|
|
||||||
|
// Green palette for heatmap cells
|
||||||
|
const HMAP_FILL = [
|
||||||
|
"rgba(16,185,129,0.08)", // 0 — empty
|
||||||
|
"rgba(16,185,129,0.28)", // 1
|
||||||
|
"rgba(16,185,129,0.55)", // 2
|
||||||
|
"rgba(16,185,129,0.78)", // 3
|
||||||
|
"rgba(16,185,129,1.00)", // 4 — full emerald
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
function isoDate(d: Date): string {
|
function isoDate(d: Date): string {
|
||||||
return [
|
return [d.getFullYear(), String(d.getMonth()+1).padStart(2,"0"), String(d.getDate()).padStart(2,"0")].join("-");
|
||||||
d.getFullYear(),
|
|
||||||
String(d.getMonth() + 1).padStart(2, "0"),
|
|
||||||
String(d.getDate()).padStart(2, "0"),
|
|
||||||
].join("-");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtLines(n: number): string {
|
function fmtLines(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`;
|
||||||
if (n >= 1_000) return `${(n/1_000).toFixed(1)}k`;
|
if (n >= 1_000) return `${(n/1_000).toFixed(1)}k`;
|
||||||
return n.toLocaleString();
|
return n.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtRelative(iso: string): string {
|
function fmtRelative(iso: string): string {
|
||||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
if (s < 60) return "just now";
|
if (s < 60) return "just now";
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s/60); if (m < 60) return `${m}m ago`;
|
||||||
if (m < 60) return `${m}m ago`;
|
const h = Math.floor(m/60); if (h < 24) return `${h}h ago`;
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
return `${Math.floor(h/24)}d ago`;
|
return `${Math.floor(h/24)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catmull-Rom → cubic bezier smooth path
|
||||||
|
function smoothPath(pts: {x:number;y:number}[]): string {
|
||||||
|
if (pts.length < 2) return "";
|
||||||
|
const t = 1/6;
|
||||||
|
let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`;
|
||||||
|
for (let i = 0; i < pts.length-1; i++) {
|
||||||
|
const p0=pts[Math.max(0,i-1)], p1=pts[i], p2=pts[i+1], p3=pts[Math.min(pts.length-1,i+2)];
|
||||||
|
d += ` C${(p1.x+t*(p2.x-p0.x)).toFixed(1)},${(p1.y+t*(p2.y-p0.y)).toFixed(1)} ${(p2.x-t*(p3.x-p1.x)).toFixed(1)},${(p2.y-t*(p3.y-p1.y)).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Line chart sub-component ───────────────────────────────────────────────
|
||||||
|
function LineChart({ days, range, onRangeChange }: {
|
||||||
|
days: ForgejoHeatmapDay[];
|
||||||
|
range: RangeKey;
|
||||||
|
onRangeChange: (r: RangeKey) => void;
|
||||||
|
}) {
|
||||||
|
const { points, xLabels, yLabels, linePath, areaPath } = useMemo(() => {
|
||||||
|
const lookup = new Map(days.map(d => [d.date, d.count]));
|
||||||
|
const numDays = RANGE_DAYS[range];
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
const daily = Array.from({length: numDays}, (_, i) => {
|
||||||
|
const d = new Date(today); d.setDate(d.getDate()-(numDays-1-i));
|
||||||
|
const key = isoDate(d);
|
||||||
|
return { date: key, count: lookup.get(key) ?? 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const useWeekly = numDays > 30;
|
||||||
|
const series = useWeekly
|
||||||
|
? Array.from({length: Math.ceil(daily.length/7)}, (_, i) => {
|
||||||
|
const chunk = daily.slice(i*7, i*7+7);
|
||||||
|
return { date: chunk[0].date, count: chunk.reduce((s,d) => s+d.count, 0) };
|
||||||
|
})
|
||||||
|
: daily;
|
||||||
|
|
||||||
|
const localMax = Math.max(...series.map(p => p.count), 1);
|
||||||
|
const pts = series.map((p, i) => ({
|
||||||
|
x: LP_L + (i / Math.max(series.length-1,1)) * LCW,
|
||||||
|
y: LP_T + LCH - (p.count / localMax) * LCH,
|
||||||
|
date: p.date, count: p.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// X labels
|
||||||
|
const step = Math.max(1, Math.floor(series.length / 5));
|
||||||
|
const xLabels = series
|
||||||
|
.filter((_, i) => i % step === 0 || i === series.length-1)
|
||||||
|
.map((p) => {
|
||||||
|
const idx = series.indexOf(p);
|
||||||
|
const d = new Date(p.date);
|
||||||
|
return {
|
||||||
|
x: LP_L + (idx / Math.max(series.length-1,1)) * LCW,
|
||||||
|
label: useWeekly || numDays > 7
|
||||||
|
? `${MONTHS[d.getMonth()]} ${d.getDate()}`
|
||||||
|
: ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Y labels
|
||||||
|
const yStep = localMax / 4;
|
||||||
|
const yLabels = [1,2,3,4].map(i => {
|
||||||
|
const val = Math.round(yStep * i);
|
||||||
|
return { y: LP_T + LCH - (val/localMax)*LCH, label: val >= 1000 ? `${(val/1000).toFixed(0)}k` : String(val) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const linePath = smoothPath(pts);
|
||||||
|
const last = pts[pts.length-1], first = pts[0];
|
||||||
|
const baseline = LP_T + LCH;
|
||||||
|
const areaPath = pts.length < 2 ? "" :
|
||||||
|
linePath + ` L${last.x.toFixed(1)},${baseline} L${first.x.toFixed(1)},${baseline} Z`;
|
||||||
|
|
||||||
|
return { points: pts, xLabels, yLabels, linePath, areaPath };
|
||||||
|
}, [days, range]);
|
||||||
|
|
||||||
|
const baseline = LP_T + LCH;
|
||||||
|
const lastPt = points[points.length-1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" style={{ color: VIOLET }} />
|
||||||
|
<span className="text-sm font-semibold text-strong">Git Activity</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{RANGE_LABELS.map(r => (
|
||||||
|
<button
|
||||||
|
key={r} type="button"
|
||||||
|
onClick={() => onRangeChange(r)}
|
||||||
|
className="rounded px-2 py-0.5 text-xs font-medium transition-all"
|
||||||
|
style={r === range
|
||||||
|
? { background:"rgba(139,92,246,0.22)", color:VIOLET, border:"1px solid rgba(139,92,246,0.40)" }
|
||||||
|
: { background:"transparent", color:W40, border:"1px solid transparent" }}
|
||||||
|
>{r}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG */}
|
||||||
|
<svg viewBox={`0 0 ${LVW} ${LVH}`} width="100%" style={{ display:"block", overflow:"visible" }}
|
||||||
|
aria-label={`Git activity last ${range}`}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="la-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(139,92,246,0.50)" />
|
||||||
|
<stop offset="80%" stopColor="rgba(139,92,246,0.05)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(139,92,246,0.00)" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="la-clip"><rect x={LP_L} y={LP_T-2} width={LCW} height={LCH+4}/></clipPath>
|
||||||
|
</defs>
|
||||||
|
{yLabels.map(({y, label}) => (
|
||||||
|
<g key={label}>
|
||||||
|
<line x1={LP_L} y1={y} x2={LP_L+LCW} y2={y} strokeDasharray="4 5" style={{stroke:W12,strokeWidth:1}}/>
|
||||||
|
<text x={LP_L-5} y={y+3.5} fontSize={9} textAnchor="end" style={{fill:W40}}>{label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<line x1={LP_L} y1={baseline} x2={LP_L+LCW} y2={baseline} style={{stroke:W12,strokeWidth:1}}/>
|
||||||
|
<path d={areaPath} style={{fill:"url(#la-fill)"}} clipPath="url(#la-clip)"/>
|
||||||
|
<path d={linePath} style={{fill:"none",stroke:VIOLET,strokeWidth:2,strokeLinejoin:"round",strokeLinecap:"round"}} clipPath="url(#la-clip)"/>
|
||||||
|
{lastPt && (
|
||||||
|
<>
|
||||||
|
<circle cx={lastPt.x} cy={lastPt.y} r={5.5} style={{fill:"rgba(10,10,30,0.9)"}}/>
|
||||||
|
<circle cx={lastPt.x} cy={lastPt.y} r={3.5} style={{fill:VIOLET}}/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{xLabels.map(({x, label}, i) => (
|
||||||
|
<text key={i} x={x} y={LVH-6} fontSize={10} textAnchor="middle" style={{fill:W70}}>{label}</text>
|
||||||
|
))}
|
||||||
|
{points.map((p,i) => (
|
||||||
|
<rect key={i} x={p.x - LCW/Math.max(points.length,1)/2} y={LP_T} width={LCW/Math.max(points.length,1)} height={LCH} style={{fill:"transparent"}}>
|
||||||
|
<title>{p.date}{p.count > 0 ? `: ${p.count} event${p.count!==1?"s":""}` : ": no activity"}</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heatmap grid sub-component ─────────────────────────────────────────────
|
||||||
|
function HeatmapGrid({ days }: { days: ForgejoHeatmapDay[] }) {
|
||||||
|
const { weeks, monthLabels } = useMemo(() => {
|
||||||
|
const lookup = new Map(days.map(d => [d.date, d.count]));
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
const start = new Date(today);
|
||||||
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
start.setDate(start.getDate() - (HWEEKS-1)*7);
|
||||||
|
|
||||||
|
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 < HWEEKS; 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: lookup.get(dateStr) ?? 0, future: cur > today });
|
||||||
|
cur.setDate(cur.getDate()+1);
|
||||||
|
}
|
||||||
|
builtWeeks.push(week);
|
||||||
|
}
|
||||||
|
return { weeks: builtWeeks, monthLabels: monthLabelList };
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LayoutGrid className="h-4 w-4" style={{ color: "rgba(16,185,129,1)" }} />
|
||||||
|
<span className="text-sm font-semibold text-strong">Activity Heatmap</span>
|
||||||
|
</div>
|
||||||
|
<span className="rounded px-2 py-0.5 text-xs font-medium"
|
||||||
|
style={{ background:"rgba(16,185,129,0.12)", color:"rgba(16,185,129,0.85)", border:"1px solid rgba(16,185,129,0.25)" }}>
|
||||||
|
Last 12 months
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG */}
|
||||||
|
<svg viewBox={`0 0 ${HVW} ${HVH}`} width="100%" style={{ display:"block" }} aria-label="Activity heatmap">
|
||||||
|
{/* Month labels */}
|
||||||
|
{monthLabels.map(({weekIdx, label}) => (
|
||||||
|
<text key={`m-${weekIdx}`} x={HLEFT+weekIdx*HSTRIDE} y={11} fontSize={10} style={{fill:W70}}>{label}</text>
|
||||||
|
))}
|
||||||
|
{/* Day labels */}
|
||||||
|
{(["Mon","Wed","Fri"] as const).map((label, i) => (
|
||||||
|
<text key={label} x={0} y={HTOP+(i*2+1)*HSTRIDE+HCELL-2} fontSize={10} style={{fill:W70}}>{label}</text>
|
||||||
|
))}
|
||||||
|
{/* Cells */}
|
||||||
|
{weeks.map((week, wi) =>
|
||||||
|
week.map((cell, di) => cell.future ? null : (
|
||||||
|
<rect key={cell.date}
|
||||||
|
x={HLEFT+wi*HSTRIDE} y={HTOP+di*HSTRIDE}
|
||||||
|
width={HCELL} height={HCELL} rx={2}
|
||||||
|
style={{fill: HMAP_FILL[toLevel(cell.count)]}}>
|
||||||
|
<title>{cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}`+"" : ": no activity"}</title>
|
||||||
|
</rect>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{/* Legend */}
|
||||||
|
<text x={HLEFT} y={HVH-4} fontSize={9} style={{fill:W40}}>Less</text>
|
||||||
|
{HMAP_FILL.map((fill, i) => (
|
||||||
|
<rect key={i} x={HLEFT+32+i*(HCELL+3)} y={HVH-HCELL-4} width={HCELL} height={HCELL} rx={2} style={{fill}}/>
|
||||||
|
))}
|
||||||
|
<text x={HLEFT+32+HMAP_FILL.length*(HCELL+3)+2} y={HVH-4} fontSize={9} style={{fill:W40}}>More</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main export ────────────────────────────────────────────────────────────
|
||||||
export function ForgejoHeatmap({
|
export function ForgejoHeatmap({
|
||||||
days,
|
days,
|
||||||
maxCount,
|
|
||||||
totalAdditions = 0,
|
totalAdditions = 0,
|
||||||
totalDeletions = 0,
|
totalDeletions = 0,
|
||||||
hasLineStats = false,
|
hasLineStats = false,
|
||||||
lastPush = null,
|
lastPush = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ForgejoHeatmapProps) {
|
}: ForgejoHeatmapProps) {
|
||||||
const { points, monthLabels, gridLines, peak } = useMemo(() => {
|
const [lineRange, setLineRange] = useState<RangeKey>("30d");
|
||||||
const lookup = new Map(days.map((d) => [d.date, d.count]));
|
|
||||||
|
|
||||||
// Build a dense 183-day array ending today
|
const totalEvents = useMemo(() => {
|
||||||
const today = new Date();
|
const numDays = RANGE_DAYS[lineRange];
|
||||||
today.setHours(0, 0, 0, 0);
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
const series: { date: string; count: number }[] = [];
|
const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - numDays);
|
||||||
for (let i = DAYS - 1; i >= 0; i--) {
|
const cutoffStr = isoDate(cutoff);
|
||||||
const d = new Date(today);
|
return days.filter(d => d.date >= cutoffStr).reduce((s,d) => s+d.count, 0);
|
||||||
d.setDate(d.getDate() - i);
|
}, [days, lineRange]);
|
||||||
const key = isoDate(d);
|
|
||||||
series.push({ date: key, count: lookup.get(key) ?? 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const localMax = Math.max(...series.map((p) => p.count), 1);
|
|
||||||
|
|
||||||
// Map to SVG coords
|
|
||||||
const pts = series.map((p, i) => ({
|
|
||||||
x: PAD_L + (i / (DAYS - 1)) * CW,
|
|
||||||
y: PAD_T + CH - (p.count / localMax) * CH,
|
|
||||||
date: p.date,
|
|
||||||
count: p.count,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Month label positions
|
|
||||||
const labels: { x: number; label: string }[] = [];
|
|
||||||
let lastMonth = -1;
|
|
||||||
series.forEach((p, i) => {
|
|
||||||
const m = new Date(p.date).getMonth();
|
|
||||||
if (m !== lastMonth) {
|
|
||||||
labels.push({ x: PAD_L + (i / (DAYS - 1)) * CW, label: MONTHS[m] });
|
|
||||||
lastMonth = m;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Horizontal grid lines at 25 / 50 / 75 % of chart height
|
|
||||||
const grid = [0.25, 0.5, 0.75].map((f) => PAD_T + CH * (1 - f));
|
|
||||||
|
|
||||||
// Peak point (highest count)
|
|
||||||
const peak = pts.reduce((best, p) => (p.count > best.count ? p : best), pts[0]);
|
|
||||||
|
|
||||||
return { points: pts, monthLabels: labels, gridLines: grid, peak };
|
|
||||||
}, [days, maxCount]);
|
|
||||||
|
|
||||||
// Build SVG path strings
|
|
||||||
const lineD = useMemo(() => {
|
|
||||||
if (!points.length) return "";
|
|
||||||
return points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
|
|
||||||
}, [points]);
|
|
||||||
|
|
||||||
const areaD = useMemo(() => {
|
|
||||||
if (!points.length) return "";
|
|
||||||
const bottom = PAD_T + CH;
|
|
||||||
return (
|
|
||||||
`M${PAD_L},${bottom} ` +
|
|
||||||
points.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") +
|
|
||||||
` L${(PAD_L + CW).toFixed(1)},${bottom} Z`
|
|
||||||
);
|
|
||||||
}, [points]);
|
|
||||||
|
|
||||||
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div className="w-full animate-pulse rounded-lg" style={{ height: VH, background: "rgba(139,92,246,0.08)" }} />
|
{[0,1].map(i => (
|
||||||
<div className="mx-auto h-6 w-64 animate-pulse rounded" style={{ background: "rgba(139,92,246,0.08)" }} />
|
<div key={i} className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4">
|
||||||
|
<div className="mb-3 flex justify-between">
|
||||||
|
<div className="h-4 w-28 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/>
|
||||||
|
<div className="h-4 w-20 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/>
|
||||||
|
</div>
|
||||||
|
<div className="animate-pulse rounded" style={{height:160,background:"rgba(255,255,255,0.05)"}}/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -140,135 +338,38 @@ export function ForgejoHeatmap({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{/* ── Area chart ──────────────────────────────────────────── */}
|
{/* ── Two cards ───────────────────────────────────────────── */}
|
||||||
<svg
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
viewBox={`0 0 ${VW} ${VH}`}
|
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
|
||||||
width="100%"
|
<LineChart days={days} range={lineRange} onRangeChange={setLineRange} />
|
||||||
aria-label="Git activity graph"
|
</section>
|
||||||
style={{ display: "block", overflow: "visible" }}
|
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
|
||||||
>
|
<HeatmapGrid days={days} />
|
||||||
<defs>
|
</section>
|
||||||
<linearGradient id="git-area-gradient" x1="0" y1="0" x2="0" y2="1">
|
</div>
|
||||||
<stop offset="0%" stopColor="rgba(139,92,246,0.45)" />
|
|
||||||
<stop offset="100%" stopColor="rgba(139,92,246,0.02)" />
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="git-chart-clip">
|
|
||||||
<rect x={PAD_L} y={PAD_T} width={CW} height={CH} />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Grid lines */}
|
|
||||||
{gridLines.map((y, i) => (
|
|
||||||
<line
|
|
||||||
key={i}
|
|
||||||
x1={PAD_L} y1={y} x2={PAD_L + CW} y2={y}
|
|
||||||
style={{ stroke: WHITE15, strokeWidth: 1 }}
|
|
||||||
strokeDasharray="3 4"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Filled area */}
|
|
||||||
<path
|
|
||||||
d={areaD}
|
|
||||||
style={{ fill: "url(#git-area-gradient)" }}
|
|
||||||
clipPath="url(#git-chart-clip)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Line */}
|
|
||||||
<path
|
|
||||||
d={lineD}
|
|
||||||
style={{ fill: "none", stroke: VIOLET, strokeWidth: 1.8, strokeLinejoin: "round", strokeLinecap: "round" }}
|
|
||||||
clipPath="url(#git-chart-clip)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Peak dot + label */}
|
|
||||||
{peak && peak.count > 0 && (
|
|
||||||
<>
|
|
||||||
<circle cx={peak.x} cy={peak.y} r={4} style={{ fill: VIOLET }} />
|
|
||||||
<circle cx={peak.x} cy={peak.y} r={7} style={{ fill: "rgba(139,92,246,0.20)" }} />
|
|
||||||
<text
|
|
||||||
x={Math.min(Math.max(peak.x, PAD_L + 16), PAD_L + CW - 16)}
|
|
||||||
y={peak.y - 12}
|
|
||||||
fontSize={10}
|
|
||||||
textAnchor="middle"
|
|
||||||
style={{ fill: WHITE70 }}
|
|
||||||
>
|
|
||||||
{peak.count}
|
|
||||||
</text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Today dot */}
|
|
||||||
{points.length > 0 && (
|
|
||||||
<circle
|
|
||||||
cx={points[points.length - 1].x}
|
|
||||||
cy={points[points.length - 1].y}
|
|
||||||
r={3}
|
|
||||||
style={{ fill: VIOLET }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* X-axis baseline */}
|
|
||||||
<line
|
|
||||||
x1={PAD_L} y1={PAD_T + CH} x2={PAD_L + CW} y2={PAD_T + CH}
|
|
||||||
style={{ stroke: WHITE15, strokeWidth: 1 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Month labels */}
|
|
||||||
{monthLabels.map(({ x, label }, i) => (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={x}
|
|
||||||
y={VH - 8}
|
|
||||||
fontSize={10}
|
|
||||||
style={{ fill: WHITE70 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Invisible hit targets for per-day tooltip */}
|
|
||||||
{points.map((p) => (
|
|
||||||
<rect
|
|
||||||
key={p.date}
|
|
||||||
x={p.x - CW / (DAYS * 2)}
|
|
||||||
y={PAD_T}
|
|
||||||
width={CW / DAYS}
|
|
||||||
height={CH}
|
|
||||||
style={{ fill: "transparent", cursor: "crosshair" }}
|
|
||||||
>
|
|
||||||
<title>{p.date}{p.count > 0 ? `: ${p.count} event${p.count !== 1 ? "s" : ""}` : ": no activity"}</title>
|
|
||||||
</rect>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* ── Contributions summary ────────────────────────────────── */}
|
{/* ── Contributions summary ────────────────────────────────── */}
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
<span className="text-2xl font-bold tabular-nums" style={{color:VIOLET}}>
|
<span className="text-2xl font-bold tabular-nums" style={{color:VIOLET}}>
|
||||||
{totalEvents.toLocaleString()}
|
{totalEvents.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-1.5 text-sm" style={{ color: WHITE70 }}>
|
<span className="ml-1.5 text-sm" style={{color:W70}}>
|
||||||
contributions across all tracked repositories in the last 6 months
|
contributions across all tracked repositories in the last {lineRange}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Last push ───────────────────────────────────────────── */}
|
{/* ── Last push — no box, plain inline ────────────────────── */}
|
||||||
{lastPush && (
|
{lastPush && (
|
||||||
<div
|
<div className="mx-auto flex max-w-lg items-start gap-3 px-1">
|
||||||
className="mx-auto flex max-w-lg items-start gap-3 rounded-lg px-4 py-2.5"
|
<span className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold"
|
||||||
style={{ background: "rgba(139,92,246,0.08)", border: "1px solid rgba(139,92,246,0.18)" }}
|
style={{background:"rgba(139,92,246,0.20)",color:VIOLET}}>
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold"
|
|
||||||
style={{ background: "rgba(139,92,246,0.20)", color: VIOLET }}
|
|
||||||
>
|
|
||||||
{lastPush.sha}
|
{lastPush.sha}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium" style={{color:"rgba(255,255,255,0.90)"}}>
|
<p className="truncate text-sm font-medium" style={{color:"rgba(255,255,255,0.90)"}}>
|
||||||
{lastPush.message}
|
{lastPush.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs" style={{ color: WHITE50 }}>
|
<p className="mt-0.5 text-xs" style={{color:"rgba(255,255,255,0.50)"}}>
|
||||||
{lastPush.author} pushed to{" "}
|
{lastPush.author} pushed to{" "}
|
||||||
<span style={{color:"rgba(139,92,246,0.90)"}}>{lastPush.branch}</span>
|
<span style={{color:"rgba(139,92,246,0.90)"}}>{lastPush.branch}</span>
|
||||||
{" · "}{lastPush.repo}{" · "}{fmtRelative(lastPush.pushed_at)}
|
{" · "}{lastPush.repo}{" · "}{fmtRelative(lastPush.pushed_at)}
|
||||||
|
|
@ -277,25 +378,22 @@ export function ForgejoHeatmap({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Line stats ──────────────────────────────────────────── */}
|
{/* ── Line stats — boxed ──────────────────────────────────── */}
|
||||||
{hasLineStats ? (
|
{hasLineStats ? (
|
||||||
<div className="flex items-center justify-center gap-8 pt-1">
|
<div className="mx-auto flex max-w-xs items-center justify-center gap-8 rounded-xl px-8 py-4"
|
||||||
|
style={{background:"rgba(139,92,246,0.07)", border:"1px solid rgba(139,92,246,0.20)"}}>
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span className="text-2xl font-bold tabular-nums" style={{ color: GREEN }}>
|
<span className="text-2xl font-bold tabular-nums" style={{color:GREEN}}>+{fmtLines(totalAdditions)}</span>
|
||||||
+{fmtLines(totalAdditions)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium" style={{color:"rgba(52,211,153,0.70)"}}>lines added</span>
|
<span className="text-xs font-medium" style={{color:"rgba(52,211,153,0.70)"}}>lines added</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: 1, height: 40, background: "rgba(139,92,246,0.20)" }} />
|
<div style={{width:1,height:40,background:"rgba(139,92,246,0.25)"}}/>
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span className="text-2xl font-bold tabular-nums" style={{ color: RED }}>
|
<span className="text-2xl font-bold tabular-nums" style={{color:RED}}>-{fmtLines(totalDeletions)}</span>
|
||||||
-{fmtLines(totalDeletions)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium" style={{color:"rgba(248,113,113,0.70)"}}>lines removed</span>
|
<span className="text-xs font-medium" style={{color:"rgba(248,113,113,0.70)"}}>lines removed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-xs" style={{ color: WHITE70, opacity: 0.6 }}>
|
<p className="text-center text-xs" style={{color:W70,opacity:0.6}}>
|
||||||
Line stats syncing with Forgejo — will appear on next refresh
|
Line stats syncing with Forgejo — will appear on next refresh
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -134,10 +134,7 @@ export function DashboardSidebar() {
|
||||||
return (
|
return (
|
||||||
<aside className="fixed inset-y-0 left-0 z-40 flex w-[280px] -translate-x-full flex-col border-r border-[color:var(--border)] bg-[linear-gradient(180deg,var(--surface)_0%,var(--surface-muted)_100%)] pt-16 shadow-lg transition-transform duration-200 ease-in-out [[data-sidebar=open]_&]:translate-x-0 md:relative md:inset-auto md:z-auto md:w-[260px] md:translate-x-0 md:pt-0 md:shadow-none md:transition-none">
|
<aside className="fixed inset-y-0 left-0 z-40 flex w-[280px] -translate-x-full flex-col border-r border-[color:var(--border)] bg-[linear-gradient(180deg,var(--surface)_0%,var(--surface-muted)_100%)] pt-16 shadow-lg transition-transform duration-200 ease-in-out [[data-sidebar=open]_&]:translate-x-0 md:relative md:inset-auto md:z-auto md:w-[260px] md:translate-x-0 md:pt-0 md:shadow-none md:transition-none">
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-5">
|
<div className="flex-1 overflow-y-auto px-3 py-5">
|
||||||
<p className="px-3 font-heading text-lg font-bold text-[color:var(--text)]">
|
<nav className="space-y-5">
|
||||||
Navigation
|
|
||||||
</p>
|
|
||||||
<nav className="mt-5 space-y-5">
|
|
||||||
<div>
|
<div>
|
||||||
<p className={sectionHeaderClass}>Overview</p>
|
<p className={sectionHeaderClass}>Overview</p>
|
||||||
<div className="mt-2 space-y-1.5">
|
<div className="mt-2 space-y-1.5">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue