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 { 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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue