feat: add hover tooltips to all charts and increase project legend font

- PieChart (org dashboard): tooltip on hover shows project name, time and %
- BarChart (member detail): tooltip on hover shows period label and value
- MiniPieChart (member detail): tooltip on hover shows project name, time and %
- Increase project stats legend font from text-[10px] to text-xs (12px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabriel Santana de Menezes 2026-05-09 21:05:15 -04:00
parent 92847f4095
commit 6e77f7b194
2 changed files with 178 additions and 47 deletions

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { X, Mail, Clock, CheckCircle2, Loader2, AlertCircle, TrendingUp, Zap } from "lucide-react"; import { X, Mail, Clock, CheckCircle2, Loader2, AlertCircle, TrendingUp, Zap } from "lucide-react";
import { format, differenceInDays } from "date-fns"; import { format, differenceInDays } from "date-fns";
@ -27,6 +27,24 @@ function getPeriodLabel(s: PeriodStats, groupedBy: "DAY" | "MONTH"): string {
return `${MONTH_NAMES[s.month - 1]}/${String(s.year).slice(2)}`; return `${MONTH_NAMES[s.month - 1]}/${String(s.year).slice(2)}`;
} }
interface TooltipState { x: number; y: number; content: string }
function ChartTooltip({ tooltip }: { tooltip: TooltipState }) {
const above = tooltip.y > 40;
return (
<div
className="absolute pointer-events-none z-20 bg-surface border border-border-soft rounded-lg px-2 py-1 text-xs font-bold text-text-main shadow-lg whitespace-nowrap"
style={{
left: tooltip.x,
top: above ? tooltip.y - 34 : tooltip.y + 8,
transform: "translateX(-50%)",
}}
>
{tooltip.content}
</div>
);
}
function Avatar({ name, photo }: { name: string; photo?: string | null }) { function Avatar({ name, photo }: { name: string; photo?: string | null }) {
if (photo) return <img src={photo} alt={name} className="w-14 h-14 rounded-2xl object-cover shrink-0" />; if (photo) return <img src={photo} alt={name} className="w-14 h-14 rounded-2xl object-cover shrink-0" />;
const initials = name.split(" ").slice(0, 2).map((n) => n[0]).join("").toUpperCase(); const initials = name.split(" ").slice(0, 2).map((n) => n[0]).join("").toUpperCase();
@ -42,12 +60,23 @@ function BarChart({
valueKey, valueKey,
color, color,
groupedBy, groupedBy,
formatValue,
}: { }: {
data: PeriodStats[]; data: PeriodStats[];
valueKey: "finishedTasksCount" | "totalMinutesWorked"; valueKey: "finishedTasksCount" | "totalMinutesWorked";
color: string; color: string;
groupedBy: "DAY" | "MONTH"; groupedBy: "DAY" | "MONTH";
formatValue: (v: number) => string;
}) { }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
const values = data.map((d) => d[valueKey] as number); const values = data.map((d) => d[valueKey] as number);
const maxVal = Math.max(...values, 1); const maxVal = Math.max(...values, 1);
const chartW = 220; const chartW = 220;
@ -62,30 +91,74 @@ function BarChart({
}; };
return ( return (
<svg viewBox={`0 0 ${chartW} ${barAreaH + 18}`} className="w-full"> <div className="relative">
{data.map((d, i) => { <svg
const val = d[valueKey] as number; ref={svgRef}
const barH = maxVal > 0 ? Math.max(val > 0 ? 2 : 0, Math.round((val / maxVal) * barAreaH)) : 0; viewBox={`0 0 ${chartW} ${barAreaH + 18}`}
const x = i * (barW + gap); className="w-full"
const y = barAreaH - barH; onMouseLeave={() => setTooltip(null)}
const label = showLabel(i) ? getPeriodLabel(d, groupedBy) : ""; >
{data.map((d, i) => {
const val = d[valueKey] as number;
const barH = maxVal > 0 ? Math.max(val > 0 ? 2 : 0, Math.round((val / maxVal) * barAreaH)) : 0;
const x = i * (barW + gap);
const y = barAreaH - barH;
const label = showLabel(i) ? getPeriodLabel(d, groupedBy) : "";
return ( return (
<g key={i}> <g key={i}>
<rect x={x} y={y} width={barW} height={barH} rx={2} fill={color} opacity={val === 0 ? 0.12 : 0.85} /> <rect
{label && ( x={x}
<text x={x + barW / 2} y={barAreaH + 12} textAnchor="middle" fontSize="7" fontWeight="700" fill="currentColor" opacity="0.45"> y={y}
{label} width={barW}
</text> height={barH}
)} rx={2}
</g> fill={color}
); opacity={val === 0 ? 0.12 : 0.85}
})} className="cursor-pointer transition-opacity hover:opacity-100"
</svg> onMouseMove={(e) =>
handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`)
}
/>
{/* invisible hit area for zero-height bars */}
{val === 0 && (
<rect
x={x}
y={0}
width={barW}
height={barAreaH}
fill="transparent"
onMouseMove={(e) =>
handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`)
}
/>
)}
{label && (
<text
x={x + barW / 2}
y={barAreaH + 12}
textAnchor="middle"
fontSize="7"
fontWeight="700"
fill="currentColor"
opacity="0.45"
>
{label}
</text>
)}
</g>
);
})}
</svg>
{tooltip && <ChartTooltip tooltip={tooltip} />}
</div>
); );
} }
function MiniPieChart({ data }: { data: MemberProjectStats[] }) { function MiniPieChart({ data }: { data: MemberProjectStats[] }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const total = data.reduce((s, d) => s + d.totalMinutes, 0); const total = data.reduce((s, d) => s + d.totalMinutes, 0);
if (total === 0 || data.length === 0) { if (total === 0 || data.length === 0) {
return ( return (
@ -108,17 +181,40 @@ function MiniPieChart({ data }: { data: MemberProjectStats[] }) {
const ix2 = cx + innerR * Math.cos(startAngle), iy2 = cy + innerR * Math.sin(startAngle); const ix2 = cx + innerR * Math.cos(startAngle), iy2 = cy + innerR * Math.sin(startAngle);
const largeArc = angle > Math.PI ? 1 : 0; const largeArc = angle > Math.PI ? 1 : 0;
const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z`; const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z`;
const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], name: d.projectName, fraction }; const pct = (fraction * 100).toFixed(0);
const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], label: `${d.projectName}: ${formatMinutes(d.totalMinutes)} (${pct}%)` };
startAngle = endAngle; startAngle = endAngle;
return result; return result;
}); });
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
return ( return (
<svg viewBox="0 0 100 100" className="w-full h-full"> <div className="relative w-full h-full">
{slices.map((s, i) => ( <svg
<path key={i} d={s.path} fill={s.color} stroke="var(--color-surface,#fff)" strokeWidth="1.5" /> ref={svgRef}
))} viewBox="0 0 100 100"
</svg> className="w-full h-full"
onMouseLeave={() => setTooltip(null)}
>
{slices.map((s, i) => (
<path
key={i}
d={s.path}
fill={s.color}
stroke="var(--color-surface,#fff)"
strokeWidth="1.5"
className="cursor-pointer transition-opacity hover:opacity-90"
onMouseMove={(e) => handleMouseMove(e, s.label)}
/>
))}
</svg>
{tooltip && <ChartTooltip tooltip={tooltip} />}
</div>
); );
} }
@ -257,6 +353,7 @@ export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
valueKey="finishedTasksCount" valueKey="finishedTasksCount"
color="#7C3AED" color="#7C3AED"
groupedBy={data.groupedBy} groupedBy={data.groupedBy}
formatValue={(v) => `${v} tarefa${v !== 1 ? "s" : ""}`}
/> />
</div> </div>
@ -272,6 +369,7 @@ export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
valueKey="totalMinutesWorked" valueKey="totalMinutesWorked"
color="#06B6D4" color="#06B6D4"
groupedBy={data.groupedBy} groupedBy={data.groupedBy}
formatValue={formatMinutes}
/> />
</div> </div>
</div> </div>
@ -297,7 +395,7 @@ export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
<div key={p.projectId ?? "geral"} className="flex items-center gap-2"> <div key={p.projectId ?? "geral"} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: CHART_COLORS[i % CHART_COLORS.length] }} /> <div className="w-2 h-2 rounded-full shrink-0" style={{ background: CHART_COLORS[i % CHART_COLORS.length] }} />
<span className="text-xs font-bold text-text-main truncate flex-1">{p.projectName}</span> <span className="text-xs font-bold text-text-main truncate flex-1">{p.projectName}</span>
<span className="text-[10px] font-black text-text-muted shrink-0"> <span className="text-xs font-black text-text-muted shrink-0">
{formatMinutes(p.totalMinutes)} ({pct}%) {formatMinutes(p.totalMinutes)} ({pct}%)
</span> </span>
</div> </div>

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useRef, useState, useCallback } from "react";
import { MemberDetailModal } from "../components/MemberDetailModal"; import { MemberDetailModal } from "../components/MemberDetailModal";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
@ -35,7 +35,12 @@ const CHART_COLORS = [
"#EC4899", "#EC4899",
]; ];
interface TooltipState { x: number; y: number; content: string }
function PieChart({ data }: { data: OrgProjectStats[] }) { function PieChart({ data }: { data: OrgProjectStats[] }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const total = data.reduce((sum, d) => sum + d.totalMinutes, 0); const total = data.reduce((sum, d) => sum + d.totalMinutes, 0);
if (total === 0 || data.length === 0) { if (total === 0 || data.length === 0) {
return ( return (
@ -76,30 +81,58 @@ function PieChart({ data }: { data: OrgProjectStats[] }) {
"Z", "Z",
].join(" "); ].join(" ");
const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], fraction, name: d.projectName }; const pct = ((fraction) * 100).toFixed(1);
const result = {
path,
color: CHART_COLORS[i % CHART_COLORS.length],
fraction,
label: `${d.projectName}: ${formatMinutes(d.totalMinutes)} (${pct}%)`,
};
startAngle = endAngle; startAngle = endAngle;
return result; return result;
}); });
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
return ( return (
<svg viewBox="0 0 200 200" className="w-full h-full"> <div className="relative w-full h-full">
{slices.map((slice, i) => ( <svg
<path ref={svgRef}
key={i} viewBox="0 0 200 200"
d={slice.path} className="w-full h-full"
fill={slice.color} onMouseLeave={() => setTooltip(null)}
stroke="var(--color-surface, #fff)" >
strokeWidth="2" {slices.map((slice, i) => (
className="transition-opacity hover:opacity-80" <path
/> key={i}
))} d={slice.path}
<text x={cx} y={cy - 8} textAnchor="middle" className="text-xs" fontSize="11" fill="currentColor" opacity="0.5"> fill={slice.color}
projetos stroke="var(--color-surface, #fff)"
</text> strokeWidth="2"
<text x={cx} y={cy + 10} textAnchor="middle" fontSize="20" fontWeight="900" fill="currentColor"> className="cursor-pointer transition-opacity hover:opacity-80"
{data.length} onMouseMove={(e) => handleMouseMove(e, slice.label)}
</text> />
</svg> ))}
<text x={cx} y={cy - 8} textAnchor="middle" className="text-xs" fontSize="11" fill="currentColor" opacity="0.5">
projetos
</text>
<text x={cx} y={cy + 10} textAnchor="middle" fontSize="20" fontWeight="900" fill="currentColor">
{data.length}
</text>
</svg>
{tooltip && (
<div
className="absolute pointer-events-none z-20 bg-surface border border-border-soft rounded-lg px-2 py-1 text-xs font-bold text-text-main shadow-lg whitespace-nowrap"
style={{ left: tooltip.x, top: tooltip.y - 36, transform: "translateX(-50%)" }}
>
{tooltip.content}
</div>
)}
</div>
); );
} }