feat(heatmap): add range selection component and update range keys for activity metrics
This commit is contained in:
parent
07a7ddfb24
commit
bf986529b3
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue