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 { Activity, LayoutGrid } from "lucide-react";
import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ForgejoHeatmapProps {
days: ForgejoHeatmapDay[];
@ -30,7 +37,7 @@ const HRIGHT = 14;
const HVH = LVH;
// ── Types & data ───────────────────────────────────────────────────────────
type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y";
type RangeKey = "7d" | "14d" | "30d" | "90d" | "6m" | "1y";
type ActivityDatum = {
date: string;
count: number;
@ -38,16 +45,17 @@ type ActivityDatum = {
};
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> = {
"7d": "7 days",
"14d": "14 days",
"30d": "30 days",
"3m": "3 months",
"90d": "90 days",
"6m": "6 months",
"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"];
// ── Colors ─────────────────────────────────────────────────────────────────
@ -111,6 +119,53 @@ function heatFill(count: number, maxCount: number): string {
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 ───────────────────────────────────────────────
function LineChart({ days, range, onRangeChange, lastPush }: {
days: ForgejoHeatmapDay[];
@ -206,18 +261,12 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
{hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</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>
<RangeSelect
value={range}
onValueChange={onRangeChange}
accent="violet"
ariaLabel="Select Git Activity range"
/>
</div>
{/* SVG */}
@ -389,7 +438,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
const gap = 3;
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(
6,
Math.min(maxCell, Math.floor((availableWidth - gap * (weekCount - 1)) / weekCount)),
@ -430,17 +479,12 @@ function HeatmapGrid({ days, range, onRangeChange }: {
{hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</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(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>
<RangeSelect
value={range}
onValueChange={onRangeChange}
accent="green"
ariaLabel="Select Activity Heatmap range"
/>
</div>
{/* SVG */}
@ -543,8 +587,8 @@ export function ForgejoHeatmap({
lastPush = null,
isLoading = false,
}: ForgejoHeatmapProps) {
const [lineRange, setLineRange] = useState<RangeKey>("7d");
const [heatRange, setHeatRange] = useState<RangeKey>("7d");
const [lineRange, setLineRange] = useState<RangeKey>("14d");
const [heatRange, setHeatRange] = useState<RangeKey>("14d");
if (isLoading) {
return (

View File

@ -43,7 +43,8 @@ const formatCount = (value: number | null | undefined): string =>
const parseDate = (value: string | null | undefined): Date | 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;
};
@ -183,6 +184,7 @@ function MetricSkeleton() {
// ── Card renderer ──────────────────────────────────────────────────────────
function MetricCardLink({ card }: { card: MetricCard }) {
const Icon = card.icon;
const valueParts = card.value.split(/(\d[\d,]*)/g);
return (
<Link
@ -197,8 +199,21 @@ function MetricCardLink({ card }: { card: MetricCard }) {
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
{card.title}
</p>
<p className={cn("mt-3 break-words font-heading text-3xl font-semibold", toneValueClasses[card.tone])}>
{card.value}
<p
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>
</div>
<div