Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/components/DatePresets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ export default function DatePresets({
onSelect,
}: DatePresetsProps) {
const presets = getDatePresets();
const initialPreset = presets.find((p) => fromDate === p.from && toDate === p.to);
const [active, setActive] = useState<DatePresetId>(
(initialPreset?.id as DatePresetId) ?? "custom",
);
const matchedPreset = presets.find((p) => fromDate === p.from && toDate === p.to);
const derived = (matchedPreset?.id as DatePresetId) ?? "custom";
const [showCustom, setShowCustom] = useState(derived === "custom");
const active = showCustom ? "custom" : derived;

return (
<div className="flex flex-col gap-2">
Expand All @@ -36,7 +36,7 @@ export default function DatePresets({
key={p.id}
active={active === p.id}
onClick={() => {
setActive(p.id);
setShowCustom(false);
setFromDate(p.from);
setToDate(p.to);
onSelect?.(p.from, p.to);
Expand All @@ -45,7 +45,7 @@ export default function DatePresets({
{p.label}
</PillButton>
))}
<PillButton active={active === "custom"} onClick={() => setActive("custom")}>
<PillButton active={active === "custom"} onClick={() => setShowCustom(true)}>
Custom
</PillButton>
</div>
Expand Down
192 changes: 134 additions & 58 deletions src/components/Heatmap.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { formatTooltipDate } from "../lib/dates";
import type { ContributionLevel, ContributionWeek } from "../lib/types";

const CELL_SIZE = 13;
const GAP = 3;
const LABEL_WIDTH = 28;

function formatTooltipDate(dateStr: string): { dayName: string; formatted: string } {
const date = new Date(`${dateStr}T00:00:00`);
const dayName = date.toLocaleDateString(undefined, { weekday: "short" });
const formatted = date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
return { dayName, formatted };
}

const LEVEL_COLORS: Record<ContributionLevel, string> = {
NONE: "var(--contrib-none)",
FIRST_QUARTILE: "var(--contrib-q1)",
Expand All @@ -26,22 +16,47 @@ const LEVEL_COLORS: Record<ContributionLevel, string> = {

const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];

/** Tooltip state including content and container-relative position. */
interface TooltipData {
date: string;
count: number;
dayName: string;
formatted: string;
/** Horizontal offset (px) from the container's left edge. */
x: number;
/** Vertical offset (px) from the container's top edge. */
y: number;
}

interface HeatmapProps {
weeks: ContributionWeek[];
}

/**
* Cached SVG layout measurements used to convert SVG coordinates
* to container-relative pixel positions for tooltip placement.
*/
interface ILayout {
/** Ratio of rendered SVG width to its viewBox width. */
scale: number;
/** SVG element's left offset relative to the container. */
svgOffsetX: number;
/** SVG element's top offset relative to the container. */
svgOffsetY: number;
}

/**
* GitHub-style contribution heatmap rendered as an SVG grid.
*
* Cells are colored by contribution quartile using CSS custom properties
* for theme support. Supports mouse hover, touch tap (toggle), click,
* and keyboard interaction for tooltip display.
*/
export default function Heatmap({ weeks }: HeatmapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
const isTouchRef = useRef(false); // Guards against mouse events firing after touch events on hybrid devices.
const layoutRef = useRef<ILayout | null>(null);
const descId = useId();

const width = LABEL_WIDTH + weeks.length * (CELL_SIZE + GAP);
Expand Down Expand Up @@ -70,71 +85,147 @@ export default function Heatmap({ weeks }: HeatmapProps) {
return labels;
}, [weeks]);

const showTooltipForTarget = useCallback((target: Element) => {
const cells = useMemo(
() =>
weeks.map((week, wi) =>
week.contributionDays.map((day) => (
<rect
key={day.date}
data-date={day.date}
data-count={day.contributionCount}
data-wi={wi}
data-wd={day.weekday}
x={LABEL_WIDTH + wi * (CELL_SIZE + GAP)}
y={24 + day.weekday * (CELL_SIZE + GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
fill={LEVEL_COLORS[day.contributionLevel] ?? LEVEL_COLORS.NONE}
/>
)),
),
[weeks],
);

/** Snapshot the SVG's position and scale so tooltip coordinates can be computed without repeated DOM reads. */
const updateLayout = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const svg = container?.querySelector("svg");
if (!container || !svg) return;
const containerRect = container.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
layoutRef.current = {
scale: svgRect.width / width,
svgOffsetX: svgRect.left - containerRect.left,
svgOffsetY: svgRect.top - containerRect.top,
};
}, [width]);

/** Read data-* attributes from a heatmap cell and return tooltip content + position. */
const computeTooltip = useCallback((target: Element): TooltipData | null => {
const dateVal = target.getAttribute("data-date");
const countVal = target.getAttribute("data-count");
if (!dateVal || countVal == null) {
setTooltip(null);
return;
}
const wiVal = target.getAttribute("data-wi");
const wdVal = target.getAttribute("data-wd");
if (!dateVal || countVal == null || !wiVal || !wdVal || !layoutRef.current) return null;

const cellRect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const wi = Number(wiVal);
const wd = Number(wdVal);
const { scale, svgOffsetX, svgOffsetY } = layoutRef.current;
const { dayName, formatted } = formatTooltipDate(dateVal);

setTooltip({
return {
date: dateVal,
count: Number(countVal),
dayName,
formatted,
x: cellRect.left - containerRect.left + cellRect.width / 2,
y: cellRect.top - containerRect.top - 6,
});
x: svgOffsetX + (LABEL_WIDTH + wi * (CELL_SIZE + GAP) + CELL_SIZE / 2) * scale,
y: svgOffsetY + (24 + wd * (CELL_SIZE + GAP)) * scale - 6,
};
}, []);

const handleMouseEnter = useCallback(() => {
isTouchRef.current = false;
updateLayout();
}, [updateLayout]);

const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => showTooltipForTarget(e.target as Element),
[showTooltipForTarget],
(e: React.MouseEvent<SVGSVGElement>) => {
if (isTouchRef.current) return;
setTooltip(computeTooltip(e.target as Element));
},
[computeTooltip],
);

const handleMouseLeave = useCallback(() => setTooltip(null), []);
const handleMouseLeave = useCallback(() => {
if (isTouchRef.current) return;
setTooltip(null);
}, []);

const toggleTooltip = useCallback(
(target: Element, stopPropagation: () => void) => {
const handleTouchEnd = useCallback(
(e: React.TouchEvent<SVGSVGElement>) => {
isTouchRef.current = true;
updateLayout();
const touch = e.changedTouches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
if (!target) return;
e.preventDefault();
const dateVal = target.getAttribute("data-date");
if (!dateVal) {
setTooltip(null);
return;
}
stopPropagation();
if (tooltip?.date === dateVal) {
setTooltip(null);
} else {
showTooltipForTarget(target);
}
setTooltip((prev) => (prev?.date === dateVal ? null : computeTooltip(target)));
},
[showTooltipForTarget, tooltip?.date],
[computeTooltip, updateLayout],
);

const handleClick = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
toggleTooltip(e.target as Element, () => e.stopPropagation());
if (isTouchRef.current) {
isTouchRef.current = false;
return;
}
const target = e.target as Element;
const dateVal = target.getAttribute("data-date");
if (!dateVal) {
setTooltip(null);
return;
}
setTooltip((prev) => (prev?.date === dateVal ? null : computeTooltip(target)));
},
[toggleTooltip],
[computeTooltip],
);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<SVGSVGElement>) => {
if (e.key === "Enter" || e.key === " ") {
toggleTooltip(e.target as Element, () => e.stopPropagation());
const target = e.target as Element;
const dateVal = target.getAttribute("data-date");
if (!dateVal) {
setTooltip(null);
return;
}
setTooltip((prev) => (prev?.date === dateVal ? null : computeTooltip(target)));
}
},
[toggleTooltip],
[computeTooltip],
);

/**
* Dismiss tooltip on outside tap/click
*/
useEffect(() => {
if (!tooltip) return;
const handleOutside = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setTooltip(null);
}
};
document.addEventListener("pointerdown", handleOutside);
return () => document.removeEventListener("pointerdown", handleOutside);
}, [tooltip]);

return (
<div ref={containerRef} className="relative mb-3.5 bg-gh-badge rounded-lg p-3">
<div id={descId} className="sr-only">
Expand All @@ -151,19 +242,19 @@ export default function Heatmap({ weeks }: HeatmapProps) {
role="img"
aria-label="Contribution heatmap"
aria-describedby={descId}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onTouchEnd={handleTouchEnd}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{/* Month labels */}
{monthLabels.map((m) => (
<text key={m.key} x={m.x} y={10} fontSize={9} fill="var(--text-secondary)">
{m.month}
</text>
))}

{/* Day labels */}
{DAY_LABELS.map(
(label, i) =>
label && (
Expand All @@ -179,22 +270,7 @@ export default function Heatmap({ weeks }: HeatmapProps) {
),
)}

{/* Contribution cells */}
{weeks.map((week, wi) =>
week.contributionDays.map((day) => (
<rect
key={day.date}
data-date={day.date}
data-count={day.contributionCount}
x={LABEL_WIDTH + wi * (CELL_SIZE + GAP)}
y={24 + day.weekday * (CELL_SIZE + GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
fill={LEVEL_COLORS[day.contributionLevel] ?? LEVEL_COLORS.NONE}
/>
)),
)}
{cells}
</svg>
</div>

Expand Down
10 changes: 10 additions & 0 deletions src/lib/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function formatTooltipDate(dateStr: string): { dayName: string; formatted: string } {
const date = new Date(`${dateStr}T00:00:00`);
const dayName = date.toLocaleDateString(undefined, { weekday: "short" });
const formatted = date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
return { dayName, formatted };
}
11 changes: 9 additions & 2 deletions src/lib/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ interface UrlState {
stats?: string[];
}

const DEFAULT_FROM_DATE = `${new Date().getFullYear()}-01-01`;
const DEFAULT_TO_DATE = `${new Date().getFullYear()}-12-31`;
function computeDefaults() {
const now = new Date();
const yearAgo = new Date(now);
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
const fmt = (d: Date) => d.toISOString().split("T")[0];
return { from: fmt(yearAgo), to: fmt(now) };
}

const { from: DEFAULT_FROM_DATE, to: DEFAULT_TO_DATE } = computeDefaults();

function encodeState(state: UrlState): string {
return btoa(JSON.stringify(state));
Expand Down
Loading