From 2d2d2c34f89d3aa9e4147b43dfa776677c3508e1 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 11 Apr 2026 23:33:56 -0400 Subject: [PATCH] Polish contribution graph UX and profile page details - Smooth tooltip with NumberFlow counter, direction-aware date animation, flip-to-left on viewport edge, show/hide motion - Eliminate mouse-move re-renders by tracking cursor position via motion values; only re-render on cell change - Memoize cell elements so tile reconciliation is skipped on hover - Add col:row map to avoid new Date() in hot path; hoist static style string - Animate ring indicator on hovered tile using SVG stroke - Add activity feed skeleton - Link participant avatars to profile pages - Tune dark/light mode contribution level colors --- apps/dashboard/package.json | 1 + .../src/components/details/detail-sidebar.tsx | 17 +- .../components/profile/contribution-graph.tsx | 343 +++++++++++------- pnpm-lock.yaml | 28 ++ 4 files changed, 245 insertions(+), 144 deletions(-) diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 3ad150a..929e82b 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -24,6 +24,7 @@ "@cloudflare/vite-plugin": "^1.26.0", "@diffkit/icons": "workspace:*", "@diffkit/ui": "workspace:*", + "@number-flow/react": "^0.6.0", "@pierre/diffs": "^1.1.12", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "~0.10.2", diff --git a/apps/dashboard/src/components/details/detail-sidebar.tsx b/apps/dashboard/src/components/details/detail-sidebar.tsx index a14e44f..a413285 100644 --- a/apps/dashboard/src/components/details/detail-sidebar.tsx +++ b/apps/dashboard/src/components/details/detail-sidebar.tsx @@ -3,6 +3,7 @@ import { TooltipContent, TooltipTrigger, } from "@diffkit/ui/components/tooltip"; +import { Link } from "@tanstack/react-router"; type DetailRowIcon = React.ComponentType<{ size?: number; @@ -68,12 +69,18 @@ export function DetailParticipantAvatars({ {actors.map((actor, index) => ( - {actor.login} 0 ? { marginLeft: -6 } : undefined} - /> + className="relative block transition-[margin] duration-200 group-hover/participants:ml-0" + > + {actor.login} + {actor.login} diff --git a/apps/dashboard/src/components/profile/contribution-graph.tsx b/apps/dashboard/src/components/profile/contribution-graph.tsx index b56eb2d..b67bbc6 100644 --- a/apps/dashboard/src/components/profile/contribution-graph.tsx +++ b/apps/dashboard/src/components/profile/contribution-graph.tsx @@ -1,5 +1,6 @@ import { cn } from "@diffkit/ui/lib/utils"; -import { motion } from "motion/react"; +import NumberFlow from "@number-flow/react"; +import { AnimatePresence, animate, motion, useMotionValue } from "motion/react"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { GitHubContributionCalendar } from "#/lib/github.types"; @@ -17,39 +18,64 @@ type CellData = { count: number; }; -type TooltipState = { - cell: CellData; - pageX: number; - pageY: number; -}; - const CELL_SIZE = 11; const CELL_GAP = 3; const CELL_STEP = CELL_SIZE + CELL_GAP; const LEVEL_COLORS_LIGHT = [ - "oklch(0.82 0.005 286)", - "oklch(0.82 0.12 150)", + "oklch(0.93 0.003 286)", + "oklch(0.84 0.10 150)", "oklch(0.72 0.16 150)", - "oklch(0.60 0.19 150)", - "oklch(0.48 0.19 150)", + "oklch(0.58 0.20 150)", + "oklch(0.42 0.19 150)", ] as const; const LEVEL_COLORS_DARK = [ - "oklch(0.25 0.006 286)", - "oklch(0.35 0.10 150)", - "oklch(0.45 0.14 150)", - "oklch(0.55 0.17 150)", - "oklch(0.65 0.19 150)", + "oklch(0.22 0.005 286)", + "oklch(0.32 0.09 150)", + "oklch(0.46 0.15 150)", + "oklch(0.62 0.20 150)", + "oklch(0.78 0.22 150)", ] as const; -function formatDate(dateStr: string) { +// Hoisted — string is constant, no reason to rebuild on every render +const CONTRIB_STYLES = ` + :root { + --contrib-level-0: ${LEVEL_COLORS_LIGHT[0]}; + --contrib-level-1: ${LEVEL_COLORS_LIGHT[1]}; + --contrib-level-2: ${LEVEL_COLORS_LIGHT[2]}; + --contrib-level-3: ${LEVEL_COLORS_LIGHT[3]}; + --contrib-level-4: ${LEVEL_COLORS_LIGHT[4]}; + } + .dark { + --contrib-level-0: ${LEVEL_COLORS_DARK[0]}; + --contrib-level-1: ${LEVEL_COLORS_DARK[1]}; + --contrib-level-2: ${LEVEL_COLORS_DARK[2]}; + --contrib-level-3: ${LEVEL_COLORS_DARK[3]}; + --contrib-level-4: ${LEVEL_COLORS_DARK[4]}; + } +`; + +function ordinalSuffix(n: number) { + const v = n % 100; + if (v >= 11 && v <= 13) return "th"; + const r = n % 10; + if (r === 1) return "st"; + if (r === 2) return "nd"; + if (r === 3) return "rd"; + return "th"; +} + +function parseDateParts(dateStr: string) { const date = new Date(dateStr); - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); + return { + month: date.toLocaleDateString("en-US", { + month: "short", + timeZone: "UTC", + }), + day: date.getUTCDate(), + year: date.getUTCFullYear(), + }; } export function ContributionGraph({ @@ -58,28 +84,34 @@ export function ContributionGraph({ }: ContributionGraphProps) { const svgRef = useRef(null); const tooltipRef = useRef(null); - const [tooltip, setTooltip] = useState(null); - const [tooltipLeft, setTooltipLeft] = useState(0); + const [activeCell, setActiveCell] = useState(null); + const activeCellRef = useRef(null); - useLayoutEffect(() => { - if (!tooltip || !tooltipRef.current) return; - const el = tooltipRef.current; - const halfWidth = el.offsetWidth / 2; - const padding = 8; - let left = tooltip.pageX; - - if (left - halfWidth < padding) { - left = halfWidth + padding; - } else if (left + halfWidth > window.innerWidth - padding) { - left = window.innerWidth - halfWidth - padding; - } + const mouseX = useMotionValue(0); + const mouseY = useMotionValue(0); + const tooltipX = useMotionValue(10); - setTooltipLeft(left); - }, [tooltip]); + useLayoutEffect(() => { + if (!activeCell || !tooltipRef.current) return; + const w = tooltipRef.current.offsetWidth; + const overflows = mouseX.get() + 10 + w > window.innerWidth - 8; + animate(tooltipX, overflows ? -10 - w : 10, { + duration: 0.15, + ease: "easeOut", + }); + }, [activeCell, mouseX, tooltipX]); - const { cells, cellsByDate } = useMemo(() => { - const result: CellData[] = []; - const map = new Map(); + const { + cells, + cellsByPosition, + svgWidth, + svgHeight, + centerCol, + centerRow, + maxDist, + } = useMemo(() => { + const cells: CellData[] = []; + const cellsByPosition = new Map(); for (let weekIdx = 0; weekIdx < calendar.weeks.length; weekIdx++) { const week = calendar.weeks[weekIdx]; @@ -92,23 +124,70 @@ export function ContributionGraph({ date: day.date, count: day.count, }; - result.push(cell); - map.set(day.date, cell); + cells.push(cell); + cellsByPosition.set(`${weekIdx}:${dayOfWeek}`, cell); } } - return { cells: result, cellsByDate: map }; + const totalCols = calendar.weeks.length; + const svgWidth = totalCols * CELL_STEP - CELL_GAP; + const svgHeight = 7 * CELL_STEP - CELL_GAP; + const centerCol = (totalCols - 1) / 2; + const centerRow = 3; // (7 - 1) / 2 + const maxDist = Math.sqrt(centerCol ** 2 + centerRow ** 2); + + return { + cells, + cellsByPosition, + svgWidth, + svgHeight, + centerCol, + centerRow, + maxDist, + }; }, [calendar.weeks]); - const totalCols = calendar.weeks.length; - const totalRows = 7; - const centerCol = (totalCols - 1) / 2; - const centerRow = (totalRows - 1) / 2; - // Max distance from center for normalization (corner cell) - const maxDist = Math.sqrt(centerCol ** 2 + centerRow ** 2); + const dateParts = useMemo( + () => (activeCell ? parseDateParts(activeCell.date) : null), + [activeCell], + ); - const svgWidth = totalCols * CELL_STEP - CELL_GAP; - const svgHeight = totalRows * CELL_STEP - CELL_GAP; + const cellElements = useMemo( + () => + cells.map((cell) => { + const col = cell.x / CELL_STEP; + const row = cell.y / CELL_STEP; + return ( + + ); + }), + [cells, centerCol, centerRow, maxDist], + ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { @@ -123,39 +202,29 @@ export function ContributionGraph({ const col = Math.floor(svgPt.x / CELL_STEP); const row = Math.floor(svgPt.y / CELL_STEP); - // Check the point is within a cell, not in the gap - const cellX = svgPt.x - col * CELL_STEP; - const cellY = svgPt.y - row * CELL_STEP; - if (cellX > CELL_SIZE || cellY > CELL_SIZE || cellX < 0 || cellY < 0) { - setTooltip(null); - return; - } + mouseX.set(e.clientX); + mouseY.set(e.clientY + 8); - const week = calendar.weeks[col]; - if (!week) { - setTooltip(null); - return; - } - - const day = week.days.find((d) => new Date(d.date).getUTCDay() === row); - if (!day) { - setTooltip(null); - return; - } - - const cell = cellsByDate.get(day.date); + const cell = cellsByPosition.get(`${col}:${row}`); if (!cell) { - setTooltip(null); + if (activeCellRef.current !== null) { + activeCellRef.current = null; + setActiveCell(null); + } return; } - setTooltip({ cell, pageX: e.clientX, pageY: e.clientY }); + if (activeCellRef.current?.date !== cell.date) { + activeCellRef.current = cell; + setActiveCell(cell); + } }, - [calendar.weeks, cellsByDate], + [cellsByPosition, mouseX, mouseY], ); const handleMouseLeave = useCallback(() => { - setTooltip(null); + activeCellRef.current = null; + setActiveCell(null); }, []); return ( @@ -175,76 +244,72 @@ export function ContributionGraph({ onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} > - {cells.map((cell) => { - const col = cell.x / CELL_STEP; - const row = cell.y / CELL_STEP; - return ( + {cellElements} + + {activeCell && ( - ); - })} + )} + - {tooltip && - createPortal( -
- - {tooltip.cell.count} contribution - {tooltip.cell.count !== 1 ? "s" : ""} - {" "} - on {formatDate(tooltip.cell.date)} -
, - document.body, - )} - - + {createPortal( + + {activeCell && dateParts && ( + + + {" "} + contribution{activeCell.count !== 1 ? "s" : ""} + {" "} + on{" "} + + + {dateParts.month} + + {" "} + + , {dateParts.year} + + )} + , + document.body, + )} + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71a6f3b..11500b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@diffkit/ui': specifier: workspace:* version: link:../../packages/ui + '@number-flow/react': + specifier: ^0.6.0 + version: 0.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@pierre/diffs': specifier: ^1.1.12 version: 1.1.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1480,6 +1483,12 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@number-flow/react@0.6.0': + resolution: {integrity: sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + '@octokit/app@16.1.2': resolution: {integrity: sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==} engines: {node: '>= 20'} @@ -3555,6 +3564,9 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -4136,6 +4148,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + number-flow@0.6.0: + resolution: {integrity: sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ==} + nuqs@2.8.9: resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==} peerDependencies: @@ -5748,6 +5763,13 @@ snapshots: '@noble/hashes@2.0.1': {} + '@number-flow/react@0.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + esm-env: 1.2.2 + number-flow: 0.6.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@octokit/app@16.1.2': dependencies: '@octokit/auth-app': 8.2.0 @@ -7871,6 +7893,8 @@ snapshots: escape-string-regexp@5.0.0: {} + esm-env@1.2.2: {} + esprima@4.0.1: {} estree-util-is-identifier-name@3.0.0: {} @@ -8669,6 +8693,10 @@ snapshots: dependencies: boolbase: 1.0.0 + number-flow@0.6.0: + dependencies: + esm-env: 1.2.2 + nuqs@2.8.9(@tanstack/react-router@1.168.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@standard-schema/spec': 1.0.0