feat(heatmap): add range selection component and update range keys for activity metrics

This commit is contained in:
null 2026-05-22 20:55:02 -05:00
parent 07a7ddfb24
commit bf986529b3
2 changed files with 92 additions and 33 deletions

View File

@ -3,6 +3,13 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Activity, LayoutGrid } from "lucide-react"; import { Activity, LayoutGrid } from "lucide-react";
import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo"; import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ForgejoHeatmapProps { interface ForgejoHeatmapProps {
days: ForgejoHeatmapDay[]; days: ForgejoHeatmapDay[];
@ -30,7 +37,7 @@ const HRIGHT = 14;
const HVH = LVH; const HVH = LVH;
// ── Types & data ─────────────────────────────────────────────────────────── // ── Types & data ───────────────────────────────────────────────────────────
type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y"; type RangeKey = "7d" | "14d" | "30d" | "90d" | "6m" | "1y";
type ActivityDatum = { type ActivityDatum = {
date: string; date: string;
count: number; count: number;
@ -38,16 +45,17 @@ type ActivityDatum = {
}; };
const RANGE_DAYS: Record<RangeKey, number> = { const RANGE_DAYS: Record<RangeKey, number> = {
"7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365, "7d": 7, "14d": 14, "30d": 30, "90d": 90, "6m": 183, "1y": 365,
}; };
const RANGE_SUMMARY: Record<RangeKey, string> = { const RANGE_SUMMARY: Record<RangeKey, string> = {
"7d": "7 days", "7d": "7 days",
"14d": "14 days",
"30d": "30 days", "30d": "30 days",
"3m": "3 months", "90d": "90 days",
"6m": "6 months", "6m": "6 months",
"1y": "1 year", "1y": "1 year",
}; };
const RANGE_LABELS: RangeKey[] = ["7d", "30d", "3m", "6m", "1y"]; const RANGE_LABELS: RangeKey[] = ["7d", "14d", "30d", "90d", "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 ───────────────────────────────────────────────────────────────── // ── Colors ─────────────────────────────────────────────────────────────────
@ -111,6 +119,53 @@ function heatFill(count: number, maxCount: number): string {
return `rgba(16,185,129,${alpha.toFixed(2)})`; return `rgba(16,185,129,${alpha.toFixed(2)})`;
} }
function RangeSelect({
value,
onValueChange,
accent,
ariaLabel,
}: {
value: RangeKey;
onValueChange: (value: RangeKey) => void;
accent: "violet" | "green";
ariaLabel: string;
}) {
const isViolet = accent === "violet";
return (
<Select value={value} onValueChange={(next) => onValueChange(next as RangeKey)}>
<SelectTrigger
aria-label={ariaLabel}
className="h-8 w-[86px] rounded-lg px-2.5 text-xs font-semibold shadow-none focus:ring-offset-0"
style={
isViolet
? {
background: "rgba(139,92,246,0.12)",
borderColor: "rgba(139,92,246,0.34)",
color: VIOLET,
}
: {
background: "rgba(16,185,129,0.12)",
borderColor: "rgba(16,185,129,0.34)",
color: GREEN,
}
}
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" className="min-w-[8rem]">
{RANGE_LABELS.map((range) => (
<SelectItem key={range} value={range}>
<span className="flex w-full items-center justify-between gap-4">
<span>{range}</span>
<span className="text-xs text-muted">{RANGE_SUMMARY[range]}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ── Line chart sub-component ─────────────────────────────────────────────── // ── Line chart sub-component ───────────────────────────────────────────────
function LineChart({ days, range, onRangeChange, lastPush }: { function LineChart({ days, range, onRangeChange, lastPush }: {
days: ForgejoHeatmapDay[]; days: ForgejoHeatmapDay[];
@ -206,18 +261,12 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
{hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`} {hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</span> </span>
</div> </div>
<div className="flex gap-1"> <RangeSelect
{RANGE_LABELS.map(r => ( value={range}
<button onValueChange={onRangeChange}
key={r} type="button" accent="violet"
onClick={() => onRangeChange(r)} ariaLabel="Select Git Activity range"
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> </div>
{/* SVG */} {/* SVG */}
@ -389,7 +438,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
const gap = 3; const gap = 3;
const availableWidth = LVW - HLEFT - HRIGHT; const availableWidth = LVW - HLEFT - HRIGHT;
const maxCell = range === "3m" ? 14 : range === "6m" ? 12 : 8; const maxCell = range === "90d" ? 14 : range === "6m" ? 12 : 8;
const cell = Math.max( const cell = Math.max(
6, 6,
Math.min(maxCell, Math.floor((availableWidth - gap * (weekCount - 1)) / weekCount)), Math.min(maxCell, Math.floor((availableWidth - gap * (weekCount - 1)) / weekCount)),
@ -430,17 +479,12 @@ function HeatmapGrid({ days, range, onRangeChange }: {
{hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`} {hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</span> </span>
</div> </div>
<div className="flex gap-1"> <RangeSelect
{RANGE_LABELS.map(r => ( value={range}
<button key={r} type="button" onClick={() => onRangeChange(r)} onValueChange={onRangeChange}
className="rounded px-2 py-0.5 text-xs font-medium transition-all" accent="green"
style={r === range ariaLabel="Select Activity Heatmap range"
? { background:"rgba(16,185,129,0.18)", color:"rgba(16,185,129,1)", border:"1px solid rgba(16,185,129,0.40)" } />
: { background:"transparent", color:W40, border:"1px solid transparent" }}>
{r}
</button>
))}
</div>
</div> </div>
{/* SVG */} {/* SVG */}
@ -543,8 +587,8 @@ export function ForgejoHeatmap({
lastPush = null, lastPush = null,
isLoading = false, isLoading = false,
}: ForgejoHeatmapProps) { }: ForgejoHeatmapProps) {
const [lineRange, setLineRange] = useState<RangeKey>("7d"); const [lineRange, setLineRange] = useState<RangeKey>("14d");
const [heatRange, setHeatRange] = useState<RangeKey>("7d"); const [heatRange, setHeatRange] = useState<RangeKey>("14d");
if (isLoading) { if (isLoading) {
return ( return (

View File

@ -43,7 +43,8 @@ const formatCount = (value: number | null | undefined): string =>
const parseDate = (value: string | null | undefined): Date | null => { const parseDate = (value: string | null | undefined): Date | null => {
if (!value) return null; if (!value) return null;
const date = new Date(value); const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
const date = new Date(hasTimezone ? value : `${value}Z`);
return Number.isNaN(date.getTime()) ? null : date; return Number.isNaN(date.getTime()) ? null : date;
}; };
@ -183,6 +184,7 @@ function MetricSkeleton() {
// ── Card renderer ────────────────────────────────────────────────────────── // ── Card renderer ──────────────────────────────────────────────────────────
function MetricCardLink({ card }: { card: MetricCard }) { function MetricCardLink({ card }: { card: MetricCard }) {
const Icon = card.icon; const Icon = card.icon;
const valueParts = card.value.split(/(\d[\d,]*)/g);
return ( return (
<Link <Link
@ -197,8 +199,21 @@ function MetricCardLink({ card }: { card: MetricCard }) {
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
{card.title} {card.title}
</p> </p>
<p className={cn("mt-3 break-words font-heading text-3xl font-semibold", toneValueClasses[card.tone])}> <p
{card.value} className={cn(
"mt-3 break-words font-heading text-3xl font-semibold",
toneValueClasses[card.tone],
)}
>
{valueParts.map((part, index) =>
/^\d[\d,]*$/.test(part) ? (
<span key={`${part}-${index}`} className="font-numeric">
{part}
</span>
) : (
part
),
)}
</p> </p>
</div> </div>
<div <div